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/8
Node list, 3 nodes.
Customize
Seed
3
8
Chrome

Installation

npx shadcn@latest add https://craftbits.dev/r/node-mutator.json

Usage

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

  1. One row per node. Each NodeMutatorNode renders a small accent dot, an editable label input, and a remove button. The list is keyed by node.id, so rows survive relabelling and reorder cleanly.
  2. 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.
  3. Add form. A small <form> at the foot reads the draft, trims whitespace, clamps to maxLabelLength, and emits the new node through onNodesChange. The submit button disables while the draft is blank or the list has hit maxNodes.
  4. Controlled or uncontrolled. Pass nodes for full control; pass defaultNodes to let the component own state internally; pass neither for an empty editable set.
  5. Remove with motion. Removals fade and lift slightly via AnimatePresence with mode="popLayout" — neighbours close the gap on a layout spring instead of jumping.
  6. Reduced motion. When prefers-reduced-motion: reduce is set, every row transition collapses to duration: 0 — adds and removes snap instead of animating.

Props

PropTypeDefaultDescription
nodesNodeMutatorNode[]Controlled node set. Pair with onNodesChange.
defaultNodesNodeMutatorNode[][]Uncontrolled initial node set.
onNodesChange(next: NodeMutatorNode[]) => voidFires after every add / remove / relabel.
maxNodesnumber16Hard cap on list size.
maxLabelLengthnumber24Max characters per label.
addPlaceholderstring"Add node…"Placeholder for the add input.
headingstring | null"Nodes"Visible heading; pass null to hide.
generateId(existing) => stringn0, n1, …Id for newly added nodes.
transitionTransitionSPRINGS.smoothRow enter / exit transition.
classNamestringMerged onto the root via cn().

Accessibility

  • The outer panel exposes a hidden aria-live="polite" summary like Node 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 via aria-labelledby.
  • Each label input carries aria-label="Rename node {label}"; each remove button carries aria-label="Remove node {label}". The add button uses aria-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 a phaseColor, a lessonId, and an onPredict callback 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 flat nodes: { id, label }[] controlled / uncontrolled list with no opinion on what the consumer does with the mutations.