TreeView
<TreeView /> is the canonical hierarchical-data primitive — file explorers, category
pickers, JSON inspectors, taxonomy navigation. It ships the full W3C ARIA TreeView
keyboard pattern, a roving tabindex, type-to-search, controlled / uncontrolled expansion
and selection, and an async loadChildren slot for lazy file systems.
Overview — file explorer with three expanded levels
Mental model
Tree is a
<ul role="tree">of<li role="treeitem">rows; one row holds focus at a time and the keyboard pattern dictates how focus moves between them. Selection, expansion, and async loading are bolted on top of the focus pattern — never around it.
APIs
Two equivalent APIs sharing the same DOM. Pick whichever fits the data you have.
Data-driven (default)
<TreeView
ariaLabel="Project files"
data={[
{ id: 'src', label: 'src', children: [
{ id: 'src/Button.tsx', label: 'Button.tsx' },
]},
{ id: 'README.md', label: 'README.md' },
]}
defaultExpanded={['src']}
onSelect={(id) => openFile(id)}
/><TreeView
ariaLabel="Project files"
data={[
{ id: 'src', label: 'src', children: [
{ id: 'src/Button.tsx', label: 'Button.tsx' },
]},
{ id: 'README.md', label: 'README.md' },
]}
defaultExpanded={['src']}
onSelect={(id) => openFile(id)}
/>Compound
<TreeView ariaLabel="Mailbox">
<TreeView.Node id="inbox" label="Inbox" defaultExpanded>
<TreeView.Node id="inbox/today" label="Today" />
<TreeView.Node id="inbox/week" label="This week" />
</TreeView.Node>
<TreeView.Node id="sent" label="Sent" />
</TreeView><TreeView ariaLabel="Mailbox">
<TreeView.Node id="inbox" label="Inbox" defaultExpanded>
<TreeView.Node id="inbox/today" label="Today" />
<TreeView.Node id="inbox/week" label="This week" />
</TreeView.Node>
<TreeView.Node id="sent" label="Sent" />
</TreeView>Internally the compound form is walked once and converted to the same TreeNodeData[]
shape — keyboard navigation, async loading, and selection math run from a single
projection.
Selection
selectionMode | Behaviour |
|---|---|
'none' (rare) | Tree acts as a navigation tree only — no aria-selected, no callbacks. |
'single' (default) | Click / Enter / Space sets selected. ArrowDown moves focus without changing selection (per W3C). |
'multiple' | Click toggles selection (with Ctrl/Cmd for additive). Ctrl/Cmd+A selects all visible nodes. Pair with showCheckboxes to render a leading <Checkbox>. |
<TreeView
selectionMode="multiple"
showCheckboxes
selected={selectedIds}
onSelectedChange={(next) => setSelectedIds(next as string[])}
/><TreeView
selectionMode="multiple"
showCheckboxes
selected={selectedIds}
onSelectedChange={(next) => setSelectedIds(next as string[])}
/>Use isRowSelectable (via per-node selectable: false) for "group headers" that focus
but never select.
Expansion
Clicking anywhere on a branch row — the chevron or the label — toggles expansion,
mirroring the affordance you'd expect from VS Code's explorer or Finder's sidebar. In
selectable modes the same click also updates selected, so a single tap both opens a
folder and marks it as the active node.
Uncontrolled:
<TreeView data={data} defaultExpanded={['src', 'src/components']} /><TreeView data={data} defaultExpanded={['src', 'src/components']} />Controlled:
<TreeView data={data} expanded={expanded} onExpandedChange={setExpanded} /><TreeView data={data} expanded={expanded} onExpandedChange={setExpanded} />Per-node uncontrolled (compound form):
<TreeView.Node id="x" label="X" defaultExpanded>…</TreeView.Node><TreeView.Node id="x" label="X" defaultExpanded>…</TreeView.Node>Async children
Provide loadChildren(node) and mark async-only branches with hasChildren: true. The
tree dedupes in-flight loads per id and replaces the loading sentinel with the resolved
children on success, or an error chip with a retry button on failure.
<TreeView
data={[{ id: 'root', label: 'Cloud Drive', hasChildren: true }]}
loadChildren={(node) => fetch(`/api/tree?parent=${node.id}`).then((r) => r.json())}
/><TreeView
data={[{ id: 'root', label: 'Cloud Drive', hasChildren: true }]}
loadChildren={(node) => fetch(`/api/tree?parent=${node.id}`).then((r) => r.json())}
/>The error path keeps the parent focused — consumers can retry with the inline button or
re-trigger via loadChildrenIfNeeded(node) from a custom UI.
Custom rendering
renderNode(node, state) lets you build any per-row visual you want — icons, badges,
metadata chips, status dots. Selection, focus, expansion math are all handled by the
component; the state object is read-only context.
<TreeView
data={files}
renderNode={(node, state) => (
<span className="flex items-center gap-2">
<span>{state.expanded ? '📂' : '📁'} {node.label}</span>
{node.meta?.modified && <Badge>·</Badge>}
</span>
)}
/><TreeView
data={files}
renderNode={(node, state) => (
<span className="flex items-center gap-2">
<span>{state.expanded ? '📂' : '📁'} {node.label}</span>
{node.meta?.modified && <Badge>·</Badge>}
</span>
)}
/>When renderNode is not provided, Tree renders the default chrome (chevron + optional
icon + label). Provide defaultIcon / expandedIcon / leafIcon for a quick visual
override without writing a full renderer.
Keyboard (W3C TreeView pattern)
| Key | Action |
|---|---|
ArrowDown / ArrowUp | Move focus to next / previous visible row. Skips disabled nodes. |
ArrowRight (LTR) | Collapsed branch → expand. Expanded branch → focus first child. Leaf → no-op. |
ArrowLeft (LTR) | Expanded branch → collapse. Collapsed branch / leaf → focus parent. |
Home / End | Focus first / last visible row. |
Enter / Space | Single-mode → set selection. Multi-mode → toggle selection. Branch in selectionMode="none" → expand/collapse. Enter on a branch in single-mode also toggles expansion. |
* | Expand every sibling branch under the focused node's parent. |
| Letters / digits | Type-to-search aggregator (500ms buffer). Jumps focus to the next visible row whose string label starts with the buffer. Non-string labels are skipped. |
Ctrl/Cmd+A | Multi-mode only — select all visible, selectable rows. |
The pattern automatically swaps ArrowLeft/ArrowRight semantics in RTL via the
browser's native direction-aware keyboard event flow.
Accessibility
- Root:
<ul role="tree" aria-label={ariaLabel} aria-multiselectable={selectionMode === 'multiple'}>. - Row:
<li role="treeitem" aria-level={depth} aria-posinset aria-setsize aria-expanded aria-selected aria-disabled aria-busy tabIndex={focused ? 0 : -1}>. - Children group:
<ul role="group">nested inside its parent<li>. - One roving tabindex — the tree occupies a single tab stop.
- Loading + error sentinels use
role="none"so screen-reader navigation skips them; the visible label still announces via the live region around the sentinel. - Decorative chevrons and icons are
aria-hidden="true"; the accessible name comes from the row's label<span>(or the consumer'srenderNode). - Type-to-search keystrokes are captured and
preventDefault()-ed only when they match a searchable character — text editing insiderenderNodecontrols is unaffected.
Zero jest-axe violations across default, sortable, single + multi selection, async
loading, disabled, custom render, compound, and RTL modes.
Internationalization
<I18nProvider> doesn't exist yet, so TreeView accepts a translations prop that
overlays English defaults:
<TreeView translations={{ expand: 'הרחב', collapse: 'כווץ', loading: 'טוען…' }} /><TreeView translations={{ expand: 'הרחב', collapse: 'כווץ', loading: 'טוען…' }} />| Key | Default | Used for |
|---|---|---|
expand | "Expand" | reserved — chevron aria-label when surfaced |
collapse | "Collapse" | reserved |
loading | "Loading…" | async sentinel text + Spinner accessible name |
loadError | "Failed to load" | async-error label |
retry | "Retry" | retry button label |
When <I18nProvider> lands, the same keys will be consumed from context with the prop
acting as an override.
RTL
- Indent uses
paddingInlineStartso depth flips automatically. ArrowLeft/ArrowRightsemantics swap in RTL via the browser's direction handling.showLinesconnectors useborder-inline-startso they sit on the logical start edge.
Performance
flattenTreeis memoized ondata+expandedidentity; the projection rebuilds only when the visible row list could change.- Selection / expansion lookups are
O(1)(Set). - Type-to-search is
O(visible)per keystroke — no precomputed index needed at typical tree sizes.
Anti-patterns
- Don't render arbitrary interactive controls (buttons, links) inside a row — that
defeats the single-tab-stop pattern. Use
onSelect/onSelectedChangefor actions. - Don't drive the tree with a Set or Map in
selected/expanded. The component normalises both into Sets internally; the public contract is strings and string arrays. - Don't pass
selectionMode="multiple"withoutgetRowId-equivalent stableids on every node — selection state lives on those ids. - Don't fork the visual chrome by overriding
treeItemRecipeclasses alone — write arenderNodeinstead. Recipe overrides apply to every row uniformly, butrenderNodebranches per node state.