Recursion Zoom
A zoomable visualisation of a recursion call stack. Callers describe the frames as a flat array — each frame carries an id, a call-expression label ("fib(3)", "merge(0,5)"), and an optional return value. The frame at currentDepth gets a bright accent ring; earlier frames render as resolved (returned) cards, later frames render as outline-only placeholders. Tapping any frame fires onZoom so the caller can pivot the visualisation around the new call.
Reach for it whenever the story is where the recursion currently sits — the live call chain, the unwind from leaf to root, or the moment a student picks a sub-call to expand into its own recursion.
Recursion stack with 5 frames. Currently executing fib(3) at depth 2.
Customize
Shape
5
Display
Installation
npx shadcn@latest add https://craftbits.dev/r/recursion-zoom.jsonUsage
import {
RecursionZoom,
type RecursionFrame,
} from "@craft-bits/core";
const frames: RecursionFrame[] = [
{ id: "fib-5", label: "fib(5)" },
{ id: "fib-4", label: "fib(4)" },
{ id: "fib-3", label: "fib(3)" },
{ id: "fib-2", label: "fib(2)", value: 1, tone: "resolved" },
];
<RecursionZoom frames={frames} defaultCurrentDepth={2} />Drive the focused depth from outside (e.g. wire it to a step-through scrubber):
const [depth, setDepth] = useState(0);
<RecursionZoom
frames={frames}
currentDepth={depth}
onZoom={(_id, index) => setDepth(index)}
/>Understanding the component
- Frames are a stack. Index 0 is the root call, the last entry is the deepest live call. The component renders frames top-down so the call order reads naturally.
currentDepthis the cursor. Frames before it render as resolved (returned) cards; frames at it are active; frames after it render dimmed (not-yet-visited). The Radix-patterncurrentDepthplusonZoompair gives you controlled mode;defaultCurrentDepthcovers uncontrolled.- Tone overrides depth. Each frame accepts an optional
toneof"default","resolved", or"dimmed"— useful when the depth-derived tone is wrong (e.g. a memoised hit you want to mark resolved even though it sits deeper thancurrentDepth). onZoomfires the frame id plus index. Use the id to pivot the viz around a specific call; use the index to updatecurrentDepthdirectly. The uncontrolled path updates the depth automatically.- Reduced motion.
usePrefersReducedMotion()collapses frame entries and depth transitions to instant. Focus rings still update on click — just without the spring.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
frames | readonly RecursionFrame[] | required | Ordered list of frames on the recursion call stack. |
currentDepth | number | — | Controlled current depth. |
defaultCurrentDepth | number | 0 | Uncontrolled initial depth. |
onZoom | (id: string, index: number) => void | — | Fires when a frame is tapped. |
showValues | boolean | true | Render the optional return-value pill on each frame. |
compact | boolean | false | Squeeze frame height ~20% for embedded usage. |
transition | Transition | SPRINGS.smooth | Frame transition spring. |
className | string | — | Merged onto the outer container via cn(). |
Accessibility
- The outer container is
role="group"with a screen-reader-only summary describing the stack depth and the currently-executing call. - Every frame is a real
<button>so keyboard users can tab through;EnterandSpacezoom exactly like a click. - The active frame carries
aria-current="step". Every frame'saria-labelincludes the call expression, the optional return value, and its depth on the stack. - Each frame meets a 44 px minimum hit area in both default and compact modes, satisfying WCAG 2.5.8.
- Motion respects
prefers-reduced-motion: frame entries, depth transitions, and zoom presses all collapse to instant.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/viz/RecursionZoom.tsx). The source rendered the full recursion tree as SVG circles with zoom interaction. The library version is reframed around a flat call stack —RecurrenceTreeVizalready covers the tree-shape story, soRecursionZoomspecialises on the current call chain with a zoom-into-frame affordance.