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.jsonUsage
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
- 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 labelcols = N; on success a brace right of the matrix labelsrows = M. - Phase machine.
idle(waiting for a vector pick),sending(sliding toward the matrix),fitting/bouncing(the verdict),result(output vector + annotations visible).onPhaseChangefires on every transition. - Send-and-react choreography. A
setTimeoutqueue 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. - Bound vs. unbound entries. Each input cell colours by whether it would overflow the matrix's column count — accent fill for the first
colsentries, error fill for the rest. So the visual "this won't fit" is legible even before the user hits Send. - Reduced motion. Under
prefers-reduced-motion: reduceevery step in the queue collapses to0msand every spring is replaced with{ duration: 0 }— the end-state lands instantly while the same phase machine still runs.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 2 | Initial number of matrix rows. |
cols | number | 3 | Initial number of matrix columns. |
presets | ReadonlyArray<ShapeSnapperPreset> | [2×3, 3×2, 2×2] | Preset pill row above the canvas. Selecting one swaps rows/cols. |
vectorLengths | ReadonlyArray<number> | [1, 2, 3, 4] | Vector lengths the picker offers. |
transition | Transition | SPRINGS.snap | Override the spring used for the input vector slide / bounce. |
outputTransition | Transition | SPRINGS.smooth | Override the spring used for the output vector reveal. |
onPhaseChange | (phase) => void | — | Fires whenever the phase machine transitions. |
onPresetChange | (preset, index) => void | — | Fires whenever the user selects a different preset. |
className | string | — | Merged onto the root via cn(). |
Accessibility
- The root carries
aria-labelledbypointing 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 dynamicaria-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-visiblering anddisabledstate during animation. The length-picker buttons togglearia-pressedand announce previously-failed lengths viaaria-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 to0msand 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 consumedSvgLabelfor every text run, usedChallengeBtnfor the Send button, depended on the per-lesson--color-accent-500/--color-fail-*/--color-success-*/--color-ink-*palette, and inlinedSPRINGS.snappy/SPRINGS.gentlereferences that don't exist in the canonical spring registry. The viz extract drops the lesson chrome, remaps every palette token to canonicalvar(--cb-*)semantic tokens, re-keys the springs toSPRINGS.snap/SPRINGS.smooth/SPRINGS.bouncy, and surfacesonPhaseChange/onPresetChange/transition/outputTransition/vectorLengthsso consumers can drive the walkthrough or re-skin the timing from a parent.