Call Stack Viz

A vertical function-call stack visualization. Frames are accepted bottom-firstframes[0] is the base of the stack (e.g., main()), frames.at(-1) is the currently-executing frame. New frames push in from the top (or bottom, when direction="bottom-grows"); popped frames glide out the same way via motion's layoutId-driven layout animation. Common for teaching recursion, tail calls, stack overflow, and debugger semantics.

fib(3)
fib(4)
fib(5)
main()
Customize
Stack
4
3

Installation

npx shadcn@latest add https://craftbits.dev/r/call-stack-viz.json

Usage

import { CallStackViz } from "@craft-bits/core";
 
<CallStackViz
  frames={[
    { id: "main", name: "main", line: 12 },
    { id: "fib3", name: "fib", args: "3", line: 4 },
    { id: "fib2", name: "fib", args: "2", line: 3, tone: "active" },
  ]}
/>

Drive pushes and pops from your own algorithm reducer — the component is pure render:

const [stack, setStack] = useState<CallStackFrame[]>([]);
 
<CallStackViz frames={stack} showLocals />
<button onClick={() => setStack((s) => [...s, { id: nextId(), name: "fib", args: "3" }])}>
  push
</button>
<button onClick={() => setStack((s) => s.slice(0, -1))}>
  pop
</button>

Understanding the component

  1. Bottom-first input, top-first render (by default). The frames array is read base-first — frames[0] is the deepest frame, frames.at(-1) is the active frame. The DOM reverses to put the most-recent frame at the visual top, matching the textbook stack diagram. Flip with direction="bottom-grows" to mirror Chrome DevTools, where the most-recent frame is listed last.
  2. layoutId-driven motion. Every frame carries layoutId={frame.id} and layout, so pushes / pops animate as glides instead of remount-and-fade. New frames enter with translateY(±8) + scale(0.97) + opacity 0 from the push side; popped frames exit the same direction. SPRINGS.smooth drives the transition.
  3. Per-frame tone. Each frames[i].tone is one of default, active, returning, dimmed. Tones resolve to inline CSS-var styles (--cb-accent, --cb-success, --cb-bg-muted) so consumer themes repaint the stack without re-rendering.
  4. Optional locals snapshot. When showLocals is on and the frame carries a locals map, a collapsed key/value panel sits beneath the header. The panel toggles via a <button aria-expanded> so keyboard + screen-reader users get full access.
  5. Middle truncation. When maxVisible is set and the stack exceeds it, the middle frames collapse into a single dashed … +N more marker. The base frame and the top of the stack both stay visible — this matches how stack-trace UIs typically truncate, and preserves "where we are" plus "what we started from."
  6. Reduced motion. When prefers-reduced-motion: reduce is set, the spring collapses to { duration: 0 } and frames snap in and out.

Props

PropTypeDefaultDescription
framesreadonly CallStackFrame[]requiredStack frames, bottom-first. frames[0] is the base, frames.at(-1) is the top.
direction"top-grows" | "bottom-grows""top-grows"Which way newer frames push.
showLocalsbooleanfalseRender each frame's locals snapshot as an expandable panel.
maxVisiblenumberMax visible frames — middle collapses into a +N marker when exceeded.
classNamestringMerged onto the outer <div>.

CallStackFrame

FieldTypeDescription
idstring | numberStable React + layoutId key. Must be unique within the stack.
namestringFunction name (rendered in monospace).
argsReactNodeOptional argument summary rendered inside the parentheses.
linenumberOptional active line number — surfaces a @ line N chip.
localsRecord<string, unknown>Optional locals snapshot rendered in an expandable panel.
tone"default" | "active" | "returning" | "dimmed"Semantic tone. Defaults to "default".

Accessibility

  • The stack body is role="list" with aria-live="polite", and its aria-label is rebuilt on every render ("Call stack with 4 frames. Top: fib.") so SR users hear what changed at the top after every push or pop.
  • Each frame is role="listitem" with its own aria-label ("Frame #3: fib(2) at line 3"), carries data-state="default" | "active" | "returning" | "dimmed" for testing selectors, and tags the top frame with data-top="true".
  • The locals-expand control is a real <button> with aria-expanded — fully keyboard- and screen-reader-accessible.
  • Color is never the only signal: active and returning tones each add an inset ring, dimmed reduces opacity, and every frame still renders its name + args in monospace.
  • Motion respects prefers-reduced-motion: enter / exit / layout transitions collapse to { duration: 0 } so the stack snaps.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/viz/CallStackViz.tsx). The original primitive was a single-column highlight of recursion depth keyed on a per-track trackHex and an isStale flag, used to dim the previous stack while the next answer was being authored. The library extract generalizes that surface: tones come from the --cb-* token vocabulary, frames carry structured args + line + locals, and pushes / pops animate via layoutId glides instead of fade-only transitions.