Building a Collapsible Tree with d3.hierarchy
You want a file-tree or org-chart where clicking a node folds its subtree away and clicking again unfolds it, with children sliding out from their parent instead of blinking into place.
The mechanism has two halves that people often get backwards: a data toggle that hides children from the layout, and a render toggle that animates the DOM difference. This is a focused companion to hierarchical and network layouts in D3, and it leans directly on the enter-update-exit pattern — every expand is an enter, every collapse is an exit, and the smoothness comes entirely from where those transitions start and end.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: hides children with CSS and never re-runs the layout.
function toggle(d: HierarchyNode<TreeDatum>): void {
// Children stay in node.children, so d3.tree() still allocates space for them.
const g = document.getElementById(`node-${d.data.id}`);
if (g) g.style.display = g.style.display === 'none' ? '' : 'none';
// Siblings never reflow into the gap; entering nodes pop in with no animation.
}
// ✅ FIXED: move children to a stash, re-run the layout, animate from the source.
import { hierarchy, tree, type HierarchyPointNode } from 'd3-hierarchy';
interface TreeDatum { id: string; name: string; children?: TreeDatum[]; }
// Runtime nodes gain an optional stash for hidden children.
type Node = HierarchyPointNode<TreeDatum> & { _children?: Node[]; x0?: number; y0?: number };
function toggle(d: Node, redraw: (source: Node) => void): void {
if (d.children) {
d._children = d.children as Node[]; // stash the visible subtree
d.children = undefined; // hide it FROM THE LAYOUT
} else {
d.children = d._children; // restore
d._children = undefined;
}
// PERF: only the layout re-runs (O(n)); the hierarchy object is reused, not rebuilt.
// A11Y: reflect state so assistive tech announces expand/collapse.
redraw(d); // pass the clicked node as the animation origin
}
The fix hinges on one idea: d3.tree() positions only the nodes reachable through node.children. Emptying children (after stashing into _children) genuinely removes the subtree from the layout, so remaining nodes reflow to fill the space. Restoring the array brings them back. CSS display:none leaves the nodes in the layout, so nothing moves and there is nothing to animate.
Step-by-step fix
import { tree } from 'd3-hierarchy';
import { linkHorizontal } from 'd3-shape';
import { select, type Selection } from 'd3-selection';
const layout = tree<TreeDatum>().nodeSize([26, 180]);
function update(
root: Node,
source: Node,
g: Selection<SVGGElement, unknown, null, undefined>,
): void {
layout(root); // repositions the visible nodes
const nodes = root.descendants() as Node[];
const sel = g.selectAll<SVGGElement, Node>('g.node')
.data(nodes, (d) => d.data.id);
const entering = sel.enter().append('g')
.attr('class', 'node')
.attr('role', 'treeitem')
.attr('aria-expanded', (d) => (d._children ? 'false' : d.children ? 'true' : undefined) as string)
// start at the source's OLD position so the group slides out from the click:
.attr('transform', `translate(${source.y0 ?? source.y},${source.x0 ?? source.x})`)
.style('opacity', 0);
entering.append('circle').attr('r', 4)
.attr('fill', (d) => (d._children ? '#d97706' : '#ffffff'))
.attr('stroke', '#2563eb');
entering.append('text').attr('dy', '0.32em').attr('x', 8)
.attr('font-size', 12).text((d) => d.data.name);
entering.merge(sel).transition().duration(400)
.attr('transform', (d) => `translate(${d.y},${d.x})`)
.style('opacity', 1);
sel.exit<Node>().transition().duration(400)
.attr('transform', `translate(${source.y},${source.x})`) // collapse toward the click
.style('opacity', 0)
.remove();
// Cache positions for the next transition's origin.
nodes.forEach((d) => { d.x0 = d.x; d.y0 = d.y; });
}
Verification
// After collapsing, the layout must see fewer nodes and the stash must hold them.
const before: number = root.descendants().length;
toggle(target, () => update(root, target, g));
const after: number = root.descendants().length;
console.assert(after < before, 'collapse should reduce visible node count');
console.assert(target.children === undefined && Array.isArray(target._children),
'collapsed node must keep its subtree in _children');
In DevTools, expand the SVG group in the Elements panel and click a parent: you should see <g class="node"> elements removed after the 400ms transition, not merely hidden. Toggling back must restore the exact same count, confirming _children round-trips losslessly.
Why the source position matters
The single detail that separates a polished collapsible tree from a janky one is where entering and exiting nodes animate from and to. If a new child starts at its own final coordinates and merely fades in, the eye cannot connect it to the parent that spawned it. Starting every entering group at the clicked node’s previous position — the x0/y0 you cached on the last render — makes the whole subtree appear to unfold out of the parent, which matches the user’s mental model of “opening” that node. The mirror is true on collapse: transitioning exiting nodes to the parent’s new position makes the subtree fold back into the node the user clicked. Because you cache x0/y0 after every render, the origin is always the parent’s most recent resting place, so the animation stays correct even after several expands and collapses have shifted the tree around.
Edge cases & gotchas
- Losing the stash on rebuild. If you rebuild the hierarchy from raw data on every update, the
_childrenstash is discarded and everything re-expands. Cache the root and mutate it; rebuild only when the source data actually changes. - Missing keys on the join. Without a stable id in
.data(nodes, d => d.data.id), D3 falls back to index binding, so after a collapse the wrong nodes are matched and marks animate to the wrong place. Always key on a persistent id. - Deep pre-expansion. Auto-expanding the whole tree on load defeats the purpose and can bind thousands of nodes at once. Start collapsed below the first level and let the user drill in.
aria-expandedon leaves. Settingaria-expandedon a node with no children is invalid ARIA. Only emit it for nodes that havechildrenor_children.- Interrupted transitions. Rapid clicking can start a new layout before the last 400ms transition ends, leaving nodes mid-flight. Name the transitions or interrupt the previous one so positions resolve cleanly.
nodeSizevssize. Using.size()rescales the whole tree into a fixed box each toggle, so nodes jump size as the count changes. Use.nodeSize()for stable spacing and let the container scroll.
Frequently Asked Questions
Why use _children instead of hiding nodes with CSS?
Because d3.tree() only lays out nodes reachable via node.children. Moving a subtree into _children genuinely removes it from the layout, so the remaining nodes reflow into the freed space and the tree gets visibly smaller. CSS display:none leaves the nodes in the layout, so nothing repositions and there is no space to animate into.
How do I make children slide out from their parent?
Cache each node’s position as x0/y0 after every render. When new children enter, set their initial transform to the clicked parent’s x0/y0, then transition them to their own computed position. On collapse, transition exiting nodes back to the parent’s current position before removing them, so the subtree appears to fold into the node you clicked.
Do I need to rebuild the hierarchy on every toggle?
No, and you should not. Build d3.hierarchy() once and cache the root, then only mutate children/_children and re-run the layout generator on each toggle. Rebuilding the hierarchy discards your _children stash and cached positions, which loses the collapsed state and breaks the enter/exit animations.
Related
- Hierarchical and network layouts in D3 — the overview covering the tidy-tree pattern this extends.
- Enter-update-exit pattern mastery — the join semantics behind the expand/collapse animation.
- Animating D3 enter and exit selections smoothly — choreographing the transitions.
- Fixing treemap tile overlap and padding — a sibling space-filling layout.