Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
src
ui
Button.tsx
Input.tsx
Overlay.tsx
main.tsx
README.md

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.

src
ui
Button.tsx
Input.tsx
Overlay.tsx
main.tsx
README.md

→ on a closed folder opens it; → again descends into it. ← closes, then climbs.

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.

With guides
src
ui
Button.tsx
Input.tsx
Overlay.tsx
main.tsx
README.md
Without
src
ui
Button.tsx
Input.tsx
Overlay.tsx
main.tsx
README.md

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.

Organisation
Region
Team
Project
Environment
Deployment

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.

src
Collapsed
src
Expanded
Button.tsx
Leaf
Button.tsx
Selected
Input.tsx
Focus
nav.ts
Nested
Loading branch
No files
Empty branch

Anatomy

Every part, every measurement, and the reason it is that number.

src
ui
Button.tsx
Input.tsx
Overlay.tsx
main.tsx
README.md

One tab stop, roving focus, and a chevron that only appears on nodes that actually have children.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Icon13px, muted

    Type, not decoration: open folder, closed folder, file kind. It is the second signal that a node is expanded.

  6. 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.

  7. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
indentDepth
gapChevron to icon to label

Radius

TokenValueUsed for
--radius-smRow corners

Typography

TokenValueUsed for
--text-label-smRow labels

Motion

TokenValueUsed for
--duration-fastChevron rotation

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightIconLabel gapTypeMin widthTouch targetWhen to use
Compact24px12px6px12px——File explorers and inspectors, where vertical density is the point.
Default28px13px6px12px——The default. Category pickers and taxonomies.
Touch36px————Full-row targetThe 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.

Do

role="tree" · focused.tabIndex = 0
everything else = −1
Give the whole tree one tab stopRoving tabindex is what makes a 400-node tree passable. Without it the tree is a wall a keyboard user has to tab through to reach anything after it.
→ on closed folder → opens it→ on open folder → moves to first child← on open folder → closes it← on a leaf → moves to its parent
Make → open, then descendTwo presses of the same key to open a folder and enter it is the model every file explorer has taught. ← closes, then climbs to the parent.
key “b” → next node starting with b
Support typeaheadTyping "b" jumping to Button.tsx is the difference between a usable tree and a theoretical one. It comes almost free with the roving focus you already built.
usePersistentState('tree:open', ['src', 'ui'])
Persist the expanded setA tree that collapses on every navigation makes the user rebuild their context each time. Store the open node ids, not the whole tree.

Don't

README.md
Do not put a chevron on a leafIt promises children that do not exist. After two dead chevrons users stop trusting the affordance on real parents, and the whole tree becomes trial and error.
▸ Dashboard▸ Settings▸ Billing
Do not use a tree for flat navigationGrouped sidebar links are not a hierarchy. Wrapping them in tree semantics promises arrow-key traversal and depth that the data does not have.
deployment-4021.json
Do not nest past four levelsThe indent eats the label and users lose the thread. Deep data needs search over full paths, not a wider panel.
click folder → expand + select + fetch contents + navigate
Do not make expanding and selecting the same eventOpening a folder to see inside is not choosing it. If expanding also loads the folder into the detail panel, every exploration triggers work the user did not ask for.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA4.1.2Name, Role, ValueA

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

TabEnters 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 / EndJumps to the first or last visible node.
EnterActivates a leaf, or toggles a parent.
A–ZTypeahead 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.
AttributeApplied toNotes
role="tree"The containerWith aria-label. Promises the full arrow-key model above — do not use it without implementing that.
role="treeitem"Every nodeBoth parents and leaves. The distinction is aria-expanded, not the role.
aria-expandedParents onlyIts absence is what tells assistive tech a node is a leaf. Never set it to false on something with no children.
aria-levelEvery nodeOne-indexed depth. This is how depth reaches a screen-reader user, since indentation does not.
aria-selectedEvery nodeWith aria-multiselectable on the tree when more than one can be selected.
aria-setsize / aria-posinsetNodes in a virtualised treeRequired once rows are windowed, or "3 of 400" becomes "3 of 20".

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
selectedstring | 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.
guidesbooleantrueIndent 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.

Notes

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.