Skip to content
Back to Design Hub

Tree View Component Design

Recursive data, expand/collapse, multi-select, and ARIA tree roles for hierarchical navigation.

Component PatternsAdvanced

Why

Trees are the right primitive for file systems, org charts, and nested categories. A flat list with indentation fakes it but breaks keyboard semantics and screen-reader level reporting.

Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.

When

Use a tree for 2+ levels of hierarchy where users expand/collapse nodes (file explorer, category nav). For 1 level, use a list; for sibling-only navigation, use a menu.

How

1. ARIA — role="tree" on container, role="treeitem" on nodes, aria-expanded, aria-level, aria-setsize, aria-posinset 2. Keyboard — Up/Down between visible items, Right expands or moves to first child, Left collapses or moves to parent 3. Data — recursive { id, label, children?[] }; flatten to visible list for rendering + keyboard nav 4. Selection — single (click selects) or multi (checkbox per node, parent indeterminate when some children selected) 5. Virtualization — for >1000 nodes, render only visible window with react-window or @tanstack/virtual 6. Lazy load — children fetched on first expand, show spinner until resolved 7. Indentation — aria-level drives padding, not nested DOM (flat is faster + virtualizable)

Example

// Flatten recursive tree to visible items (expanded nodes' children included) function flatten(nodes: Node[], expanded: Set<string>, level = 1): VisibleNode[] { return nodes.flatMap((n) => { const self = { ...n, level, hasChildren: !!n.children?.length }; if (!n.children || !expanded.has(n.id)) return [self]; return [self, ...flatten(n.children, expanded, level + 1)]; }); } function Tree({ nodes }: { nodes: Node[] }) { const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<string | null>(null); const visible = flatten(nodes, expanded); const onKeyDown = (e, id) => { const i = visible.findIndex((v) => v.id === id); if (e.key === "ArrowDown" && i < visible.length - 1) focus(visible[i + 1].id); if (e.key === "ArrowUp" && i > 0) focus(visible[i - 1].id); if (e.key === "ArrowRight") setExpanded((p) => new Set(p).add(id)); if (e.key === "ArrowLeft") setExpanded((p) => { const n = new Set(p); n.delete(id); return n; }); }; return ( <ul role="tree"> {visible.map((v) => ( <li key={v.id} role="treeitem" aria-level={v.level} aria-expanded={expanded.has(v.id)} aria-selected={selected === v.id} onKeyDown={(e) => onKeyDown(e, v.id)} onClick={() => setSelected(v.id)} style={{ paddingLeft: v.level * 16 }}> {v.hasChildren ? (expanded.has(v.id) ? "▾" : "▸") : "•"} {v.label} </li> ))} </ul> ); }

Common Problems

  • ⚠Nested DOM rendering — 5 levels deep = 5 nested <ul>, slow and impossible to virtualize
  • ⚠Missing aria-level/setsize/posinset — screen readers can't announce depth or position in set
  • ⚠Arrow keys move DOM focus but not aria-activedescendant — screen reader doesn't follow

Best Practices

  • ✓Flatten to a visible list with aria-level — flat DOM is fast and virtualizable
  • ✓Report aria-setsize and aria-posinset — screen readers announce '3 of 7, level 2'
  • ✓Virtualize for >1000 nodes — render only the visible window plus a small overscan buffer

FAQ