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