Tree

A hierarchical list with one field per node — folders, a site map, a category picker — that expands and collapses, moves with arrow keys as one tab stop, and optionally selects nodes. The APG tree view.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Tree } from '@design-schema/react';

Examples

  • Notes
import { Tree, type TreeNode } from '@design-schema/react';

const NODES: TreeNode[] = [
  {
    id: 'docs',
    label: 'Documents',
    icon: 'folder',
    badge: '3',
    children: [
      { id: 'invoices', label: 'Invoices', icon: 'file' },
      { id: 'contracts', label: 'Contracts', icon: 'file' },
      { id: 'archive', label: 'Archive', icon: 'file', disabled: true },
    ],
  },
  {
    id: 'media',
    label: 'Media',
    icon: 'folder',
    children: [
      { id: 'photos', label: 'Photos', icon: 'folder', children: [{ id: 'holiday', label: 'Holiday', icon: 'file' }] },
      { id: 'videos', label: 'Videos', icon: 'folder', children: 'lazy' },
    ],
  },
  { id: 'notes', label: 'Notes', icon: 'file' },
];

<Tree label="Folders" nodes={NODES} />

Props

12 rows
PropTypeDefaultDescriptionPlatforms
label requiredstring—

What the tree lists ("Folders", "Categories"). Not visible unless `showLabel`.

Accessibility: aria-label / accessibilityLabel on the tree.

web, lit, rn, swiftui
showLabelbooleanfalse

Show the label as a Heading above the tree (then the tree is aria-labelledby it instead of aria-label).

web, lit, rn, swiftui
headingLevel2 | 3 | 42

Heading level of the visible label in the page outline; its size is headingSize regardless. On React Native the level has no observable effect — Heading sets the header trait at every level and headingSize pins the size — so the prop is accepted and forwarded for parity only, as DataGrid's captionLevel.

web, lit, rn, swiftui
nodes requiredarray TreeNode[] where TreeNode = { id: string; label: string; icon?: IconName; badge?: string; disabled?: boolean; href?: string; children?: TreeNode[] | "lazy" }—

The hierarchy. `href` makes a node's label a Link with `tone: inherit` nested inside the label Text, so it takes the label's font and color (navigation trees); `icon` is an Icon glyph (`folder` and `file` exist for the usual case); `badge` is a short trailing count or status; `children: "lazy"` loads on first expand through `onExpand`.

web, lit, rn, swiftui
expandedarray string[]—

Controlled expanded ids. A still-`"lazy"` id here is held closed until the user opens it, exactly as in `defaultExpanded` — the id stays in the array the caller passed and in what onExpandChange reports, but the node does not render open and fires no onExpand.

web, lit, rn, swiftui
defaultExpandedarray string[]—

Initially expanded ids. `["*"]` opens every node whose `children` is a non-empty array and never a `"lazy"` node, as TreeGrid; `"*"` is reserved as that sentinel, so it is never matched against an id — a node whose id is literally `"*"` is opened only as any other loaded parent is, and is never treated as explicitly listed (so a lazy one stays closed). The first user toggle resolves `"*"` to the concrete ids then open, and that resolved array is what onExpandChange reports, any held-lazy ids kept in place, as TreeGrid. A lazy id listed explicitly stays closed until the user opens it (onExpand only fires for user acts), and the same rule covers the controlled `expanded`.

web, lit, rn, swiftui
selectablenone | single | multiplesingle

`single`: one current node (the usual for navigation and pickers). `multiple`: checkbox-like selection with Space, Shift+arrows and Ctrl+A; parents are checkboxes that cascade when `selectChildren`. `none`: expand/collapse only.

web, lit, rn, swiftui
selectedarray string[]—

Controlled selected ids. Always an array, even in `single` mode (zero or one element).

web, lit, rn, swiftui
defaultSelectedarray string[]—

Initially selected ids.

web, lit, rn, swiftui
selectChildrenbooleanfalse

With `multiple`, selecting a parent selects its descendants and parents show indeterminate. The cascade covers loaded, enabled descendants only (a `"lazy"` subtree contributes nothing until loaded); a parent's id is in `selected` exactly when all its enabled loaded descendants are, and unchecking any descendant removes it and every ancestor id. Its aria-checked (true, mixed, false) is derived from its descendants. A disabled node is skipped as a target but does not wall off its subtree: its enabled loaded descendants still cascade. A parent with no enabled loaded descendants at all (a still-lazy subtree, or only disabled children) behaves as a leaf and carries just its own id. Shift+ArrowDown/Up cascade like Space but only ever add — they never remove a node or an ancestor.

web, lit, rn, swiftui
selectOnFocusbooleanfalse

With `single`, moving focus also selects (a settings sidebar where the tree drives a panel). Moving focus means the keyboard — the arrows, Home, End and type-ahead; entering the tree with Tab and focus arriving from a pointer never select on their own (a click already selects, and mere entry should not report a selection change). Off by default: focus moves, Enter or Space selects.

web, lit, rn, swiftui
showGuidesbooleantrue

Vertical guide lines under open parents.

web, lit, rn, swiftui

Events

4 rows
EventTypeDescriptionPlatforms
onSelectionChangeonSelectionChange / selection-change

Fired with the selected ids, in tree (document) order, and only when the set actually changes (Space on the already-selected node, or selectOnFocus landing on it, fires nothing).

web, lit, rn, swiftui
onExpandChangeonExpandChange / expand-change

Fired with the expanded ids, in the order they were opened.

web, lit, rn, swiftui
onExpandonExpand / expand

Fired with its id each time a node whose `children` is still `"lazy"` is opened, so a failed load can retry (as TreeGrid): closing and reopening the node fires it again, and replacing `children` is the only thing that stops it — it is not once per mount. It fires before the onExpandChange of the same act.

web, lit, rn, swiftui
onActivateonActivate / activate

Fired on Enter or double-click on a node (open the file, navigate), with its id. Nodes with `href` navigate instead.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Required props that carry accessibility meaning: label. Each is described in the props table above.

Implicit ARIA role: tree.

Anatomy

containerheadingnodenodeRowexpandButtonindenticonlabellinkbadgecheckboxgroupemptyState

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Arrow navigation — Arrow keys move between items; Home and End jump to the ends.

Roving tabindex — The group is a single tab stop; the arrow keys move within it.

Expanded state — Open and closed are exposed programmatically (`aria-expanded`), not only visually.

Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).

Live region — Changes are announced through a live region, without moving focus.

Tree keyboard support

13 rows
KeyAction
TabMoves into the tree (to the selected node — the first selected in tree order when several are — else the first node) and out of it — one tab stop. Leaving the tree forgets which node was focused, so re-entry follows the same rule instead of restoring it. Every key in this table is handled by the nearest enclosing treeitem, so a key pressed while focus sits on a composed control inside a node (the chevron Button, an `href` Link — both tabindex=-1 but still focusable) still drives the tree.
ArrowDownNext visible node.
ArrowUpPrevious visible node.
ArrowRightOn a closed parent: opens it. On an open parent: moves to its first enabled child (disabled nodes are skipped). On a leaf: nothing.
ArrowLeftOn an open parent: closes it. Otherwise: moves to the parent; when the parent is disabled, focus stays put.
HomeFirst node.
EndLast visible node.
EnterActivates the node (onActivate, or follows href); with `selectable: single`, also selects it first. An href node is followed by clicking its composed link (so the page's click routing sees it) and does not fire onActivate.
SpaceSelects (single) or toggles selection (multiple) of the focused node. On rn a tap on the row does this instead — Pressable has no key events. (selectable)
*Opens every enabled sibling of the focused node, the focused node included; lazy siblings open and fire onExpand.
Shift+ArrowDown or Shift+ArrowUpMoves focus to the next / previous node and adds it to the selection (the APG rule; no anchor range). Outside `multiple` they act as plain arrows. (multiple)
Control+aAdds every visible, enabled node at the current expansion state to the selection; nodes already selected inside a collapsed branch stay selected (the key never deselects). With `selectChildren` the parent invariant wins over that rule: a collapsed parent whose hidden descendants stay unselected is not added, so Ctrl+A is a no-op for it rather than breaking the invariant. Bound by key code KeyA, with Control or Meta (Cmd on macOS). (multiple)
a-zType-ahead: any printable character (letters, digits, punctuation) moves to the next visible node whose label starts with the typed characters; `*` and Space keep their own bindings and never enter the buffer. A fresh one-character buffer searches from the node after the focused one; a longer buffer may re-match the focused node (as Listbox). The buffer clears after 500 ms (literal-ok, as Listbox).

Contrast pairs

7 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foreground.mutedcolor.backgroundAA
color.foregroundcolor.background.strongAA
color.foreground.mutedcolor.background.strongAA
color.control.selectedBackgroundcolor.backgroundAA (non-text)
color.control.bordercolor.backgroundAA (non-text)
color.control.selectedForegroundcolor.control.selectedBackgroundAA

This component does not animate, so it has no reduced-motion behaviour to describe.