Forest Viz

A multi-tree forest renderer. Callers describe the forest as a flat nodes array — each node carries a stable id, an optional parentId, and an optional label / tone. Roots are nodes whose parentId is null, omitted, or self-referential (the union-find convention). The renderer groups nodes into trees, lays each tree top-down with children below their parents, and arranges the trees side-by-side along a single row.

Reach for it whenever the story is several disjoint trees at once — union-find forests, disjoint-set components, parallel recursion stacks, classification taxonomies — anything where "how many groups are there?" matters as much as "what does each group look like?".

Forest with 3 trees and 7 nodes. Focused: 1.a132b11c
Customize
Shape
3
2
Display

Installation

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

Usage

import { ForestViz, type ForestNode } from "@craft-bits/core";
 
const nodes: ForestNode[] = [
  // Tree A
  { id: "a", label: "a", tone: "resolved" },
  { id: "a1", parentId: "a", label: "1" },
  { id: "a2", parentId: "a", label: "2" },
 
  // Tree B
  { id: "b", label: "b", tone: "resolved" },
  { id: "b1", parentId: "b", label: "3" },
];
 
<ForestViz nodes={nodes} showRootMarkers />

Drive the focused node from outside (e.g. wire it to a step-through scrubber):

const [focusedId, setFocusedId] = useState(null);
 
<ForestViz
  nodes={nodes}
  focusedId={focusedId}
  onFocusedIdChange={setFocusedId}
/>

Highlight multiple nodes via the selection set — for example to colour every node on the path from a child to its root:

<ForestViz
  nodes={nodes}
  selectedIds={new Set(["a1", "a"])}
/>

Understanding the component

  1. Flat nodes, derived forest. Instead of a roots: Tree[] shape, the API takes a single flat list. Each node carries an id and an optional parentId. A node with parentId === null (or omitted, or self-referential) is a root. The renderer climbs from every non-root to determine which tree it belongs to and groups them accordingly.
  2. Side-by-side layout. Each tree is laid out top-down — the root sits on row 0, children below, and internal nodes are placed at the centroid of their visible descendants. Trees are then stacked horizontally with a fixed gap. The component sizes its outer SVG to fit, so the parent layout never has to guess.
  3. Optional treeId for grouping. Pass treeId on nodes to force them into the same tree column even when their parentId chains don't connect — useful when callers want to colour components by id or merge two visually-separate single-node trees under one heading.
  4. Focus is one node at a time. focusedId follows the Radix pattern — focusedId plus onFocusedIdChange for controlled mode, defaultFocusedId for uncontrolled. Tapping a node toggles its focus; the focused node and any incident edge highlight to accent. Tapping the focused node again clears focus.
  5. Selection is a set. selectedIds carries a persistent highlight across nodes — perfect for traced paths (every node from a child up to its root) or student-selected groups. Double-tap a node to toggle its membership; the highlight rides alongside focus without overriding it.
  6. Root markers, optionally. Set showRootMarkers to draw the union-find self-loop above each root. Off by default — most stories read fine with the root simply having no outgoing edge.
  7. Tone encodes semantic state. tone of "default", "resolved", or "dimmed" accents the ring around each node. Resolved roots get an accent border; dimmed nodes drop to ~55% opacity.
  8. Reduced motion. usePrefersReducedMotion() collapses node and edge transitions to instant. Focus rings still update on click — just without the spring.

Props

PropTypeDefaultDescription
nodesreadonly ForestNode[]requiredFlat list of every node in the forest.
focusedIdstring | nullControlled focused node id.
defaultFocusedIdstring | nullnullUncontrolled initial focused node.
onFocusedIdChange(id: string | null) => voidFires whenever the focused node changes.
selectedIdsReadonlySet<string>Controlled set of nodes carrying a persistent highlight.
defaultSelectedIdsReadonlySet<string>new Set()Uncontrolled initial selected set.
onSelectedIdsChange(ids: ReadonlySet<string>) => voidFires whenever the selected set changes.
showRootMarkersbooleanfalseDraw a self-loop arrow above each root.
compactbooleanfalseSqueeze padding ~15% for embedded usage.
transitionTransitionSPRINGS.smoothNode and edge transition.
classNamestringMerged onto the outer SVG via cn().

Accessibility

  • The outer SVG is role="img" with a <title> summarising the tree count, total node count, and the currently focused node (if any).
  • Every node carries a tabIndex'd transparent hit circle so keyboard users can tab through nodes; Enter and Space toggle focus exactly like a click.
  • Every node's hit area is at least 44 × 44 px even when the visible circle shrinks under compact, satisfying WCAG 2.5.8.
  • Color is never the only signal — every node renders its label, and the focused / selected stroke widths visibly differ from the resting state.
  • Motion respects prefers-reduced-motion: node entries, edge draws, and focus transitions all collapse to instant.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/viz/ForestViz.tsx). The original was an index-keyed primitive — parent: number[] plus nodeCount, with semantic colour overrides driven by external componentColors maps and a fixed hard-coded tone palette. The library version flips to a flat ForestNode[] shape keyed by stable id, which makes the same component work for stringy ids (graph component labels, union-find handles, recursion frame names) and exposes the Radix controlled / uncontrolled levers on focusedId and selectedIds instead of an opaque activeNode plus a separately-managed tracedPath. Self-loops are now opt-in via showRootMarkers, and styling routes through the cb-* semantic tokens so the forest sits next to the rest of the algo-viz family without visual drift.