Shape Snapper

Pick a vector length from the picker, hit Send, and watch one of two things happen: the vector slides into the matrix column-by-column (a fit), or it stops short, shakes, and the picker remembers the failure. On a fit, the matrix emits an output vector whose length equals the matrix row count — columns in, rows out.

The walkthrough drives its own five-state phase machine — idle → sending → fitting/bouncing → result — and surfaces onPhaseChange so a parent stepper or narration sibling can mirror it.

Shape snapper: 2 by 3 matrix. Pick a vector length and send it through.
Vector length:
The matrix has 3 columns. A vector must have exactly that many entries to pass through. Pick a length and send it.

The matrix has 3 columns. A vector must have exactly that many entries to pass through. Pick a length and send it.

Customize
Matrix shape
2
3

Installation

npx shadcn@latest add https://craftbits.dev/r/shape-snapper.json

Usage

import { ShapeSnapper } from "@craft-bits/viz/shape-snapper";
 
<ShapeSnapper />

Override the preset row and the vector-length options:

<ShapeSnapper
  rows={3}
  cols={4}
  presets={[
    { rows: 3, cols: 4, label: "3×4" },
    { rows: 4, cols: 3, label: "4×3" },
    { rows: 3, cols: 3, label: "3×3" },
  ]}
  vectorLengths={[2, 3, 4, 5]}
/>

Mirror the phase into a parent reducer:

<ShapeSnapper
  onPhaseChange={(phase) => {
    /* sync narration, analytics, etc. */
  }}
/>

Understanding the component

  1. Three SVG zones. The canvas (560 × 220) lays out a left "staging" column for the input vector, a center matrix grid sized by the active preset, and a right column where the output vector lands. Braces under the matrix label cols = N; on success a brace right of the matrix labels rows = M.
  2. Phase machine. idle (waiting for a vector pick), sending (sliding toward the matrix), fitting / bouncing (the verdict), result (output vector + annotations visible). onPhaseChange fires on every transition.
  3. Send-and-react choreography. A setTimeout queue paces the send. On a match the vector slides to the matrix edge, the cells pulse column-by-column, the input fades, an output vector emerges and slides to its final position, then the success annotations fade in. On a mismatch the vector stops short of the matrix, shake-keyframes play, and the picker remembers the failed length with an mark.
  4. Bound vs. unbound entries. Each input cell colours by whether it would overflow the matrix's column count — accent fill for the first cols entries, error fill for the rest. So the visual "this won't fit" is legible even before the user hits Send.
  5. Reduced motion. Under prefers-reduced-motion: reduce every step in the queue collapses to 0ms and every spring is replaced with { duration: 0 } — the end-state lands instantly while the same phase machine still runs.

Props

PropTypeDefaultDescription
rowsnumber2Initial number of matrix rows.
colsnumber3Initial number of matrix columns.
presetsReadonlyArray<ShapeSnapperPreset>[2×3, 3×2, 2×2]Preset pill row above the canvas. Selecting one swaps rows/cols.
vectorLengthsReadonlyArray<number>[1, 2, 3, 4]Vector lengths the picker offers.
transitionTransitionSPRINGS.snapOverride the spring used for the input vector slide / bounce.
outputTransitionTransitionSPRINGS.smoothOverride the spring used for the output vector reveal.
onPhaseChange(phase) => voidFires whenever the phase machine transitions.
onPresetChange(preset, index) => voidFires whenever the user selects a different preset.
classNamestringMerged onto the root via cn().

Accessibility

  • The root carries aria-labelledby pointing at a hidden <span> summary of the current phase, so screen readers hear the narrative state without parsing the SVG.
  • The SVG itself is role="img" with the same dynamic aria-label.
  • An aria-live="polite" region under the picker mirrors the narration prose, so screen-reader users hear "It fits." / "Length 3 bounced — …" without depending on visual cues.
  • Every interactive control is a real <button type="button"> with :focus-visible ring and disabled state during animation. The length-picker buttons toggle aria-pressed and announce previously-failed lengths via aria-label ("Length 4, already tried").
  • Color is never the only signal — the failed mark, the accent/error fills on overflow cells, and the on-screen narration all reinforce the success/failure verdict.
  • Motion respects prefers-reduced-motion: reduce — every staged timer collapses to 0ms and every spring is replaced with { duration: 0 }, so the same end-state lands instantly.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/math/ShapeSnapper.tsx). The source was a lesson-chrome-bound component — it consumed SvgLabel for every text run, used ChallengeBtn for the Send button, depended on the per-lesson --color-accent-500 / --color-fail-* / --color-success-* / --color-ink-* palette, and inlined SPRINGS.snappy / SPRINGS.gentle references that don't exist in the canonical spring registry. The viz extract drops the lesson chrome, remaps every palette token to canonical var(--cb-*) semantic tokens, re-keys the springs to SPRINGS.snap / SPRINGS.smooth / SPRINGS.bouncy, and surfaces onPhaseChange / onPresetChange / transition / outputTransition / vectorLengths so consumers can drive the walkthrough or re-skin the timing from a parent.