Node Mutator
A vertical list of graph nodes — each row carries a coloured dot, an inline-editable label, and a remove button — with an "add node" form at the foot. The primitive that lessons reach for whenever a learner needs to build the graph the algorithm will run on: add a vertex, rename it, drop the one that doesn't belong.
The component owns nothing about edges, weights, or layout. It is the node-set primitive, period — nodes: { id, label }[], controlled or uncontrolled. Pair with DAGRenderer, CycleRingViz, or a hand-rolled SVG to visualise what the learner built.
Nodes
3/8Customize
Seed
3
8
Chrome
Installation
npx shadcn@latest add https://craftbits.dev/r/node-mutator.jsonUsage
import { NodeMutator } from "@craft-bits/core";
<NodeMutator defaultNodes={[
{ id: "a", label: "A" },
{ id: "b", label: "B" },
]} />Drive it from outside (Radix-style controlled mode):
const [nodes, setNodes] = useState([
{ id: "a", label: "A" },
{ id: "b", label: "B" },
]);
<NodeMutator nodes={nodes} onNodesChange={(next) => setNodes([...next])} />Cap the list and tighten the label budget:
<NodeMutator
defaultNodes={[{ id: "v0", label: "v0" }]}
maxNodes={6}
maxLabelLength={8}
addPlaceholder="New vertex…"
/>Hand it a custom id generator (handy when the consumer wants alphabetic ids):
<NodeMutator
defaultNodes={[]}
generateId={(existing) => String.fromCharCode(65 + existing.length)}
/>Understanding the component
- One row per node. Each
NodeMutatorNoderenders a small accent dot, an editable label input, and a remove button. The list is keyed bynode.id, so rows survive relabelling and reorder cleanly. - Inline relabel. Focusing a label flips the row into edit mode. Press Enter (or blur) to commit. Press Escape to revert. Empty commits are rejected silently — the row bounces back to its previous label rather than vanishing on a stray backspace.
- Add form. A small
<form>at the foot reads the draft, trims whitespace, clamps tomaxLabelLength, and emits the new node throughonNodesChange. The submit button disables while the draft is blank or the list has hitmaxNodes. - Controlled or uncontrolled. Pass
nodesfor full control; passdefaultNodesto let the component own state internally; pass neither for an empty editable set. - Remove with motion. Removals fade and lift slightly via
AnimatePresencewithmode="popLayout"— neighbours close the gap on a layout spring instead of jumping. - Reduced motion. When
prefers-reduced-motion: reduceis set, every row transition collapses toduration: 0— adds and removes snap instead of animating.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | NodeMutatorNode[] | — | Controlled node set. Pair with onNodesChange. |
defaultNodes | NodeMutatorNode[] | [] | Uncontrolled initial node set. |
onNodesChange | (next: NodeMutatorNode[]) => void | — | Fires after every add / remove / relabel. |
maxNodes | number | 16 | Hard cap on list size. |
maxLabelLength | number | 24 | Max characters per label. |
addPlaceholder | string | "Add node…" | Placeholder for the add input. |
heading | string | null | "Nodes" | Visible heading; pass null to hide. |
generateId | (existing) => string | n0, n1, … | Id for newly added nodes. |
transition | Transition | SPRINGS.smooth | Row enter / exit transition. |
className | string | — | Merged onto the root via cn(). |
Accessibility
- The outer panel exposes a hidden
aria-live="polite"summary likeNode list, 3 nodes.so screen readers hear the size after each mutation. - The list is a real
<ul>of<li>rows; the heading (when shown) is announced viaaria-labelledby. - Each label input carries
aria-label="Rename node {label}"; each remove button carriesaria-label="Remove node {label}". The add button usesaria-label="Add node". - Keyboard support: type to edit, Enter to commit, Escape to revert, Tab through every row. The submit button responds to Enter inside the add input.
- Motion respects
prefers-reduced-motion— row enter / exit transitions collapse to instant.
Credits
- Extracted from:
AlgoFlashcards(src/lessons/primitives/interaction/NodeMutator.tsx). The source was wired to a specific lesson — it carried aphaseColor, alessonId, and anonPredictcallback that gated the "next" button until the learner produced the correct node set. The library extract drops the lesson chrome and exposes the underlying primitive: a flatnodes: { id, label }[]controlled / uncontrolled list with no opinion on what the consumer does with the mutations.