Tree View
Nested, expandable hierarchy with roving focus — for data that genuinely is a tree, which is far less data than the pattern gets used for.
Also called File Tree, Hierarchy, Nested List, Explorer — in this system all of them are Tree View.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The keyboard model
One tab stop for the whole tree. Try ↓ ↑ to move, → to open and descend, ← to close and climb, and any letter for typeahead.
Indent guides
A hairline per level makes it possible to trace a deep child back to its parent across a tall tree. Without them, depth 4 and depth 5 are a 14px judgement.
Depth is the real limit
Four levels is the practical ceiling. Past that the indent eats the label, and users lose the thread regardless of how the guides are drawn — the answer is search, not a wider panel.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
One tab stop, roving focus, and a chevron that only appears on nodes that actually have children.
- Row height28px (24px compact)
Denser than a sidebar row, because a tree is scanned vertically at length. On touch it grows to 36px, which is below the 44px minimum only because the whole row is the target.
- Indent per level14px
Enough to read as nesting, small enough that six levels still leave room for a filename. This single number is what caps the useful depth.
- Chevron12px, rotates 90°
Present only on nodes with children. A chevron on a leaf teaches users the affordance is meaningless, and then they stop trusting it on real parents.
- Indent guide1px at the parent’s chevron centre
Aligned to the chevron, not the label, so the line traces the actual branch. Without it, tracing a deep child to its parent across a tall tree is guesswork.
- Icon13px, muted
Type, not decoration: open folder, closed folder, file kind. It is the second signal that a node is expanded.
- SelectionLayer tint, full row
The whole row including the indent, so the selected item is unmissable in a tall tree. Distinct from the hover wash, which must never look like selection.
- Roving tabindexOne 0, rest −1
A 400-node tree is one tab stop. Anything else makes the tree a wall between the user and whatever follows it.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-fg-secondary | — | Idle row labels |
| --ds-fg-muted | — | Type icons |
| --ds-fg-disabled | — | Chevron — an affordance, not content |
| --ds-layer-hover | — | Hover wash |
| --ds-layer-selected | — | Selected row — never the hover value |
| --ds-border-subtle | — | Indent guides |
| --ds-focus-ring | — | Focus outline on the roving node |
Spacing
| Token | Value | Used for |
|---|---|---|
| indent | Depth | |
| gap | Chevron to icon to label |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-sm | Row corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label-sm | Row labels |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Chevron rotation |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Icon | Label gap | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Compact | 24px | 12px | 6px | 12px | — | — | File explorers and inspectors, where vertical density is the point. |
| Default | 28px | 13px | 6px | 12px | — | — | The default. Category pickers and taxonomies. |
| Touch | 36px | — | — | — | — | Full-row target | The row is the target, so 36px is acceptable where a 36px button would not be. |
| Indent | — | — | 14px per level | — | — | — | Fixed. Four levels is the practical ceiling before the label loses its room. |
| Chevron hit area | — | — | — | — | 20px | — | Larger than the 12px glyph, so expanding does not require pixel accuracy. |
role="tree" · focused.tabIndex = 0
everything else = −1key “b” → next node starting with busePersistentState('tree:open', ['src', 'ui'])Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Row labels owe 4.5:1 — they are the content of the component.
- Indent guides are decorative and may sit low, because depth is also carried by aria-level.
- The selected row must be distinguishable from the hover row. Reusing one token for both means a user cannot tell what is selected while the pointer is in the tree.
- Chevrons are affordances, not content, and may use the disabled tone — provided aria-expanded carries the same information.
Keyboard
| Tab | Enters the tree once, on the last-focused node, and leaves it once. |
| ↓ / ↑ | Moves to the next or previous visible node, regardless of depth. |
| → | Opens a closed parent; on an open parent, moves to its first child. |
| ← | Closes an open parent; on a leaf or closed node, moves to its parent. |
| Home / End | Jumps to the first or last visible node. |
| Enter | Activates a leaf, or toggles a parent. |
| A–Z | Typeahead to the next node beginning with that character, wrapping. |
| * | Optionally expands every sibling at the current level. Cheap to add and beloved by power users. |
Screen readers
- A node announces as "ui, tree item, level 2, expanded, 3 of 4".
- Announce asynchronous loading. A branch that opens into silence for two seconds reads as an empty folder.
- An empty branch must say so explicitly — an expanded node with no announced children is indistinguishable from a broken one.
Focus & touch
- One roving tab stop. The tree remembers the last-focused node and returns there on re-entry. When a node is removed, focus moves to its nearest sibling and then to its parent — never to the body. Collapsing a parent that contains the focused node must move focus to that parent.
- Rows grow to 36px and the chevron gets a 44px hit area of its own, so expanding does not require hitting a 12px glyph. There is no hover, so selection and expansion must be visually distinct at rest. Deep trees on a phone are usually the wrong pattern — a drill-down list that replaces the view one level at a time is easier to use and easier to go back from.
| Attribute | Applied to | Notes |
|---|---|---|
| role="tree" | The container | With aria-label. Promises the full arrow-key model above — do not use it without implementing that. |
| role="treeitem" | Every node | Both parents and leaves. The distinction is aria-expanded, not the role. |
| aria-expanded | Parents only | Its absence is what tells assistive tech a node is a leaf. Never set it to false on something with no children. |
| aria-level | Every node | One-indexed depth. This is how depth reaches a screen-reader user, since indentation does not. |
| aria-selected | Every node | With aria-multiselectable on the tree when more than one can be selected. |
| aria-setsize / aria-posinset | Nodes in a virtualised tree | Required once rows are windowed, or "3 of 400" becomes "3 of 20". |
Example usage
1// A tree is navigated as the flat list it renders as. Build that list once2// per render and every key handler becomes an index calculation.3function flatten(nodes, expanded, depth = 0) {4 return nodes.flatMap((node) => [5 { node, depth },6 ...(node.children && expanded.has(node.id)7 ? flatten(node.children, expanded, depth + 1)8 : []),9 ])10}1112const rows = flatten(tree, expanded)1314function onKeyDown(e) {15 const i = rows.findIndex((r) => r.node.id === focused)16 const { node, depth } = rows[i]17 const isParent = !!node.children18 const isOpen = expanded.has(node.id)1920 switch (e.key) {21 case 'ArrowDown': return focus(rows[i + 1])22 case 'ArrowUp': return focus(rows[i - 1])2324 // Two-step: open, then descend. This is the model every file explorer25 // has taught, and the part most implementations get wrong.26 case 'ArrowRight':27 if (isParent && !isOpen) return setOpen(node.id, true)28 if (isParent && isOpen) return focus(rows[i + 1])29 return3031 case 'ArrowLeft':32 if (isParent && isOpen) return setOpen(node.id, false)33 return focus(rows.slice(0, i).reverse().find((r) => r.depth === depth - 1))34 }35}3637// Persist the open set, not the tree. A tree that collapses on every38// navigation makes the user rebuild their context each time.39const [expanded, setExpanded] = usePersistentState('tree:open', ['src'])Framework-free HTML
<div role="tree" aria-label="Project files">
<!-- aria-expanded ONLY on parents. Its absence is what marks a leaf. -->
<div role="treeitem" aria-level="1" aria-expanded="true"
aria-setsize="3" aria-posinset="1" tabindex="0">
<svg aria-hidden="true">…</svg> src
</div>
<div role="treeitem" aria-level="2" aria-expanded="false"
aria-setsize="3" aria-posinset="1" tabindex="-1">
<svg aria-hidden="true">…</svg> docs
</div>
<!-- A leaf: no aria-expanded, no chevron. -->
<div role="treeitem" aria-level="2" aria-selected="true"
aria-setsize="3" aria-posinset="3" tabindex="-1">
<svg aria-hidden="true">…</svg> main.tsx
</div>
</div>CSS
[role='tree'] { user-select: none; }
[role='treeitem'] {
display: flex;
align-items: center;
gap: 6px;
block-size: 28px;
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--ds-fg-secondary);
/* Depth comes from a custom property so one rule covers every level. */
padding-inline-start: calc(8px + var(--depth, 0) * 14px);
}
[role='treeitem']:hover { background: var(--ds-layer-hover); }
/* Must differ from hover, or the user cannot tell what is selected while
the pointer is anywhere in the tree. */
[role='treeitem'][aria-selected='true'] {
background: var(--ds-layer-selected);
color: var(--ds-fg);
}
[role='treeitem'][aria-expanded='true'] > .ds-tree__chevron {
transform: rotate(90deg);
}
/* Aligned to the parent's chevron centre, so the line traces the branch
rather than the text. */
.ds-tree__guide {
position: absolute;
inset-block: 0;
inline-size: 1px;
background: var(--ds-border-subtle);
inset-inline-start: calc(8px + (var(--depth) - 1) * 14px + 7px);
}
@media (pointer: coarse) {
[role='treeitem'] { block-size: 36px; }
.ds-tree__chevron { padding: 12px; margin: -12px; } /* 44px without moving it */
}Component API
TreeView
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes* | TreeNode[] | — | The hierarchy. Children may be undefined (a leaf) or an empty array (a parent with nothing in it) — the two must render differently. |
| expanded* | Set<string> | — | Open node ids. Controlled, so it can be persisted. |
| onExpandedChange* | (next: Set<string>) => void | — | Fired by the chevron, by Enter on a parent, and by the arrow keys. |
| selected | string | string[] | — | An array turns on aria-multiselectable and Shift-range selection. |
| onSelect* | (id: string) => void | — | Separate from expansion. Expanding a folder must not select it. |
| guides | boolean | true | Indent guides. Turn them off only for trees that never exceed two levels. |
| loadChildren | (id: string) => Promise<TreeNode[]> | — | Lazy branches. Must render a loading row, and announce it. |
Professional tips
- Expand the path to the selected node on load, and nothing else. A tree that opens fully collapsed hides where the user already is.
- Add a filter box above the tree that matches on the full path and auto-expands the matches. In any tree past about fifty nodes this becomes the primary interaction.
- Distinguish "no children" from "children not loaded yet". A parent that expands into silence is indistinguishable from a broken request.
- Support Shift-click for a contiguous range when multi-select is on — it is what users expect from every file manager they have used.
- Give the chevron its own hit area of at least 20px. Expanding and selecting are different intents, and hitting a 12px glyph to separate them is unreasonable.
Performance
- Virtualise past roughly 200 visible rows, and add aria-setsize and aria-posinset when you do — without them a windowed tree reports "3 of 20" for a 400-node level.
- Keep expansion state in a Set of ids, not as a flag on each node. Toggling a flag deep in a nested object forces a rebuild of the whole tree on every click.
- Memoise the flattened list on the node data and the expanded set. Re-flattening on every render is the usual cause of a tree that feels sluggish to arrow through.
- Load branches lazily past a few hundred nodes, and prefetch on hover over a chevron — the fetch usually finishes before the click lands.
Common mistakes
- Making every node a tab stop, so a 400-node tree is 400 tab presses.
- aria-expanded="false" on leaves, which makes screen readers announce empty folders that do not exist.
- Right arrow that only descends and never opens, so a closed branch cannot be entered from the keyboard.
- Reusing the hover token for selection, so nothing is distinguishable while the pointer is over the tree.
- Chevrons on leaves, which trains users to distrust the affordance everywhere.
- Collapsing the whole tree on navigation, forcing the user to rebuild their context each time.
- Expanding and selecting on one event, so browsing a folder fetches and navigates.
Real-world recommendations
- File explorers are the pattern’s home ground and set every expectation your users arrive with. Deviating from the VS Code keyboard model costs more than any improvement it buys.
- Once a tree exceeds roughly fifty nodes, search overtakes expansion as the primary interaction. Build the filter before you polish the indent guides.
- Category pickers are usually better as a Combobox over flattened paths — "Electronics › Audio › Headphones" is one search away instead of three expansions.
- On mobile, a drill-down list that replaces the view one level at a time consistently beats an indented tree: back is a familiar gesture, and horizontal space is not spent on depth.