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.json

Usage

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

  1. 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.
  2. currentDepth is 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-pattern currentDepth plus onZoom pair gives you controlled mode; defaultCurrentDepth covers uncontrolled.
  3. Tone overrides depth. Each frame accepts an optional tone of "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 than currentDepth).
  4. onZoom fires the frame id plus index. Use the id to pivot the viz around a specific call; use the index to update currentDepth directly. The uncontrolled path updates the depth automatically.
  5. Reduced motion. usePrefersReducedMotion() collapses frame entries and depth transitions to instant. Focus rings still update on click — just without the spring.

Props

PropTypeDefaultDescription
framesreadonly RecursionFrame[]requiredOrdered list of frames on the recursion call stack.
currentDepthnumberControlled current depth.
defaultCurrentDepthnumber0Uncontrolled initial depth.
onZoom(id: string, index: number) => voidFires when a frame is tapped.
showValuesbooleantrueRender the optional return-value pill on each frame.
compactbooleanfalseSqueeze frame height ~20% for embedded usage.
transitionTransitionSPRINGS.smoothFrame transition spring.
classNamestringMerged 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; Enter and Space zoom exactly like a click.
  • The active frame carries aria-current="step". Every frame's aria-label includes 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 — RecurrenceTreeViz already covers the tree-shape story, so RecursionZoom specialises on the current call chain with a zoom-into-frame affordance.