Skip to content

TreeView

  • Files and folders, as in an editor’s explorer
  • Choosing from nested categories, or settings grouped in levels

Not for a site’s navigation. A tree is one Tab stop that is driven with the arrow keys, which few people expect of a menu of links. Use nested lists of Links, with an Accordion for sections that open.

Choosing between nested and grouped content:

ComponentUse it forFor example
TreeViewA hierarchy to move through and select from, any number of levelsFiles and folders, nested categories
AccordionSections of content that open one at a time or together, one levelAn FAQ, a settings page’s groups
MenuActions opened from a button, with submenus for more“More actions ⋯”, a View menu
TabsViews of one thing that people switch betweenOverview, Activity and Settings of a repo
  • src
    • main.ts
    • env.d.ts
  • package.json
  • README.md
Selected: src/main.ts
import { useState } from 'react';
import { Stack, Text, TreeView } from '@arun-dev/ui';
export default function TreeViewBasics() {
// The selection is a list of values, even with one selected: the same shape as `multiple`.
const [value, setValue] = useState<string[]>(['src/main.ts']);
return (
<Stack gap="sm" style={{ inlineSize: '100%', maxInlineSize: '20rem' }}>
<TreeView.Root
aria-label="Project files"
value={value}
onValueChange={setValue}
defaultExpanded={['src']}
>
{/* Values are paths here: unique in the tree, and useful once selected. */}
<TreeView.Item value="src" label="src">
<TreeView.Item value="src/components" label="components">
<TreeView.Item value="src/components/Button.tsx" label="Button.tsx" />
<TreeView.Item value="src/components/Card.tsx" label="Card.tsx" />
</TreeView.Item>
<TreeView.Item value="src/main.ts" label="main.ts" />
<TreeView.Item value="src/env.d.ts" label="env.d.ts" disabled />
</TreeView.Item>
<TreeView.Item value="docs" label="docs">
<TreeView.Item value="docs/intro.md" label="intro.md" />
<TreeView.Item value="docs/setup.md" label="setup.md" />
</TreeView.Item>
<TreeView.Item value="package.json" label="package.json" />
<TreeView.Item value="README.md" label="README.md" />
</TreeView.Root>
<Text size="sm" color="secondary" aria-live="polite">
Selected: {value[0] ?? 'nothing'}
</Text>
</Stack>
);
}
import { TreeView } from '@arun-dev/ui';
<TreeView.Root aria-label="Project files" defaultExpanded={['src']}>
<TreeView.Item value="src" label="src">
<TreeView.Item value="src/main.ts" label="main.ts" />
</TreeView.Item>
<TreeView.Item value="README.md" label="README.md" />
</TreeView.Root>;
PartRendersDoes
Root<ul role="tree">holds the selection and the open Items; name it with aria-label
Item<li role="treeitem">one node: a row with its label, and its child Items while it’s open

Nesting Items makes a parent. An Item with child Items opens and closes, with a chevron that turns. One without is a leaf. The row and the <ul role="group"> around the children are drawn by the Item, so there are no parts for them.

Values identify Items. Each Item’s value must be unique in the tree. A path, such as src/main.ts, is unique and useful once selected. The Root’s value lists the selected Items, and expanded lists the open ones. Each can be controlled or left to the tree.

The tree is one Tab stop: the Item last focused, or the selected one, or the first.

KeyDoes
↓ / ↑Moves to the next or previous Item shown. Does not wrap.
→Opens a closed parent; on an open one, moves to its first child
←Closes an open parent; anywhere else, moves to the Item it’s nested in
Home / EndMoves to the first or last Item shown
EnterSelects the Item, and opens or closes a parent, as a click does
SpaceSelects the Item, without opening it
*Opens every parent beside the focused Item
A letterMoves to the next Item whose label starts with what you type

In a right-to-left tree, → and ← swap, so the arrow points toward the children.

  • Repository
    • Read code
    • Push code
  • Issues
    • Read issues
    • Open and close issues
2 selected: repo:read, issues:write
import { useState } from 'react';
import { Stack, Text, TreeView } from '@arun-dev/ui';
export default function TreeViewMultiple() {
const [value, setValue] = useState<string[]>(['repo:read', 'issues:write']);
return (
<Stack gap="sm" style={{ inlineSize: '100%', maxInlineSize: '20rem' }}>
<TreeView.Root
aria-label="Token permissions"
multiple
value={value}
onValueChange={setValue}
defaultExpanded={['repo', 'issues']}
>
{/* Selecting a parent selects that Item only: the tree does not cascade to children. */}
<TreeView.Item value="repo" label="Repository">
<TreeView.Item value="repo:read" label="Read code" />
<TreeView.Item value="repo:write" label="Push code" />
</TreeView.Item>
<TreeView.Item value="issues" label="Issues">
<TreeView.Item value="issues:read" label="Read issues" />
<TreeView.Item value="issues:write" label="Open and close issues" />
</TreeView.Item>
<TreeView.Item value="admin" label="Administration">
<TreeView.Item value="admin:billing" label="Billing" />
<TreeView.Item value="admin:members" label="Members" />
</TreeView.Item>
</TreeView.Root>
<Text size="sm" color="secondary" aria-live="polite">
{value.length} selected{value.length > 0 ? `: ${value.join(', ')}` : ''}
</Text>
</Stack>
);
}

With multiple, Space, Enter or a click adds an Item to the selection or removes it. Shift with ↓ or ↑ moves and toggles the next Item, and Ctrl or ⌘ with A selects every Item shown. Selecting a parent selects that Item alone: the tree does not cascade to the children or show a parent as partly selected. If the children should follow, set them in onValueChange.

A disabled Item is drawn faded, can’t be focused, selected, opened or closed, and the arrow keys skip it. A screen reader still finds it when reading the whole tree, announced as unavailable.

label is what the row shows and what a screen reader reads, so an icon goes in it, marked aria-hidden when it adds nothing to the words. Typing moves to the next Item whose label starts with what’s typed, using the text found in label. When label is a component that renders its own text, give the Item a textValue for typing to match.

  • src
    • App.tsx
    • main.tsx
  • index.html
// value: []
// expanded: ["src"]
<TreeView.Root
  aria-label="Files"
  value={value}
  onValueChange={setValue}
  expanded={expanded}
  onExpandedChange={setExpanded}
>
  <TreeView.Item value="src" label="src">
    <TreeView.Item value="src/App.tsx" label="App.tsx" />
    <TreeView.Item value="src/main.tsx" label="main.tsx" />
  </TreeView.Item>
  <TreeView.Item value="public" label="public">
    <TreeView.Item value="public/favicon.svg" label="favicon.svg" />
  </TreeView.Item>
  <TreeView.Item value="index.html" label="index.html" />
</TreeView.Root>
  • Name the tree with aria-label, such as “Project files”.
  • Each Item is named by its label alone, not by the children nested in it.
  • aria-expanded, aria-selected and aria-level are set for you, and aria-multiselectable with multiple.
  • Selection is shown by a tint and a bar on the row’s leading edge, so it doesn’t depend on colour alone, and the focused row has its own outline.
TreeView.Root props
PropTypeDefaultDescription
valuestring[]Controlled: the selected Items' values. Provide onValueChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultValuestring[][]Initial selection when uncontrolled. Read once, at mount.
onValueChange(value: string[]) => voidCalled with the whole selection whenever it changes.
multiplebooleanfalseLets more than one Item be selected: Space, Enter or a click toggles one, Shift with an arrow key toggles the next, and Ctrl or ⌘ with A selects every Item shown. Without it, selecting an Item replaces the selection.
expandedstring[]Controlled: the open parent Items' values. Provide onExpandedChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultExpandedstring[][]Initially open parent Items when uncontrolled. Read once, at mount.
onExpandedChange(expanded: string[]) => voidCalled with every open Item whenever one opens or closes.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
TreeView.Item props
PropTypeDefaultDescription
valuestringIdentifies the Item in the Root's value and expanded. Unique in the tree. Required.
labelReactNodeWhat the row shows, and the Item's accessible name. Icons go in it too. Required.
textValuestringWhat typeahead matches, when the text in label is not it — or when label is a component that renders its own text.
disabledbooleanfalseCannot be focused, selected, opened or closed. Skipped by the arrow keys.
childrenReactNodeChild Items. With any, the Item is a parent: it opens and closes, and renders them only while open.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
  • An Item’s children render only while it’s open, so a large tree costs only what’s shown. To load children when a folder opens, set them from onExpandedChange. Until they arrive, a placeholder child Item, such as “Loading…”, keeps the folder a parent.
  • Restyle with the --tree-view-* tokens, such as --tree-view-indent or --tree-view-item-bg-selected.