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
- Documents3
- Media
- 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| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | What the tree lists ("Folders", "Categories"). Not visible unless `showLabel`. Accessibility: aria-label / accessibilityLabel on the tree. |
| showLabel | boolean | false | Show the label as a Heading above the tree (then the tree is aria-labelledby it instead of aria-label). |
| headingLevel | 2 | 3 | 4 | 2 | 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. |
| nodes required | array 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`. |
| expanded | array 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. |
| defaultExpanded | array 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`. |
| selectable | none | single | multiple | single | `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. |
| selected | array string[] | — | Controlled selected ids. Always an array, even in `single` mode (zero or one element). |
| defaultSelected | array string[] | — | Initially selected ids. |
| selectChildren | boolean | false | 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. |
| selectOnFocus | boolean | false | 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. |
| showGuides | boolean | true | Vertical guide lines under open parents. |
Events
4 rows| Event | Type | Description |
|---|---|---|
| onSelectionChange | onSelectionChange / 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). |
| onExpandChange | onExpandChange / expand-change | Fired with the expanded ids, in the order they were opened. |
| onExpand | onExpand / 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. |
| onActivate | onActivate / activate | Fired on Enter or double-click on a node (open the file, navigate), with its id. Nodes with `href` navigate instead. |
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
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.
This component does not animate, so it has no reduced-motion behaviour to describe.