TreeGrid

A DataGrid whose rows nest — accounts and sub-accounts, folders and files, a bill of materials — with expand/collapse on the row header and the grid's cell navigation everywhere else. DataGrid plus a hierarchy, not a new grid.

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 { TreeGrid } from '@design-schema/react';

Examples

Chart of accounts

Account
Code
Assets
1000
1400
Cash
1010
400
Equity
3000
1100
8 rows
import { type DataGridColumn, TreeGrid, type TreeGridRow } from '@design-schema/react';

const COLUMNS: DataGridColumn[] = [
  { key: 'account', header: 'Account', isRowHeader: true, width: 240 },
  { key: 'code', header: 'Code', width: 100 },
  { key: 'balance', header: 'Balance', align: 'end', sortable: true },
];

const DATA: TreeGridRow[] = [
  {
    id: 'assets',
    account: 'Assets',
    code: '1000',
    balance: 1400,
    children: [
      { id: 'cash', account: 'Cash', code: '1010', balance: 400 },
      {
        id: 'stock',
        account: 'Stock',
        code: '1020',
        balance: 1000,
        children: [
          { id: 'raw', account: 'Raw materials', code: '1021', balance: 600 },
          { id: 'finished', account: 'Finished goods', code: '1022', balance: 400 },
        ],
      },
    ],
  },
  {
    id: 'liabilities',
    account: 'Liabilities',
    code: '2000',
    balance: 300,
    children: [{ id: 'payable', account: 'Accounts payable', code: '2010', balance: 300 }],
  },
  { id: 'equity', account: 'Equity', code: '3000', balance: 1100 },
];

<TreeGrid
  caption="Chart of accounts"
  columns={COLUMNS}
  data={DATA}
  defaultExpanded={['assets']}
/>

Props

20 rows
PropTypeDefaultDescriptionPlatforms
caption requiredstring—

What the tree grid holds ("Chart of accounts").

Accessibility: aria-labelledby the caption / accessibilityLabel.

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

As DataGrid.

web, lit, rn, swiftui
hideCaptionbooleanfalse

Visually hide the caption; it remains the accessible name.

web, lit, rn, swiftui
columns requiredarray DataGridColumn[]—

DataGrid's column model. The `isRowHeader` column is required here: it carries the indent and the expand button, so it must exist and come first after the selection column — never pinned, never reordered, because the guide lines are measured from its start.

web, lit, rn, swiftui
data requiredarray TreeRow[] where TreeRow = { id: string; children?: TreeRow[] | "lazy"; [key: string]: unknown }—

Nested rows. `children: "lazy"` marks a row whose children are loaded on expand through `onExpand`; the row shows the expand button and a loading state until `data` is updated. `children: []` is a leaf (no expand button, no aria-expanded).

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

Controlled ids of expanded rows. A still-`"lazy"` id here is held collapsed until the user opens it, exactly as in `defaultExpanded`; the id stays in the caller's array and in what onExpandChange reports, and no onExpand fires for it.

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

Initially expanded ids. `["*"]` expands every row whose `children` is a non-empty array, including rows loaded later, and never a `"lazy"` row (that would fire onExpand without a user act); `"*"` is honoured the same way in controlled `expanded`, and the first user toggle resolves it to concrete ids, which is what onExpandChange reports. A lazy id listed explicitly stays collapsed until the user opens it, in `defaultExpanded` and in the controlled `expanded` alike — so a lazy row cannot be opened programmatically at all; the caller opens it by replacing its `children`. The `*` key is a user act and does open lazy siblings, firing onExpand for each; the native expandAll action does the same over every loaded row.

web, lit, rn, swiftui
sortobject { column: string; direction: "ascending" | "descending" }—

Sort applies within each level; siblings are ordered, hierarchy is kept.

web, lit, rn, swiftui
defaultSortobject { column: string; direction: "ascending" | "descending" }—

As DataGrid.

web, lit, rn, swiftui
selectablenone | row | cellnone

As DataGrid without `range` (rectangles across levels are not meaningful). `row` selection of a parent does not select its children unless `selectChildren`. Select-all (Ctrl+A, the select-all Checkbox) covers every loaded row at every level, expanded or not, with or without `selectChildren`.

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

As DataGrid.

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

Initially selected row ids.

web, lit, rn, swiftui
selectChildrenbooleanfalse

Toggling a parent row sets or clears its own id and every loaded descendant; a parent's shown state is derived from its loaded descendants (checked when all, even if its own id is absent; indeterminate when some; else its own id), and toggling a row shown checked clears its id and descendants while any other state sets them. An indeterminate row reports aria-selected="false". Space, Enter on the select cell, a click on its Checkbox and Ctrl/Cmd+click are all the row's own toggle and cascade; only Shift+Space and Shift+click ranges do not — the keyboard block's Shift+Space fallback, a plain toggle of the focused row when the anchor is no longer visible, is one of those ranges and stays non-cascading. A `"lazy"` subtree contributes nothing until loaded.

web, lit, rn, swiftui
editablebooleanfalse

As DataGrid.

web, lit, rn, swiftui
densitycompact | comfortablecompact

As DataGrid, its rule for a grid with a selection column included: with `selectable: "row"` the rows are comfortable at both values, because the select Checkbox is a full minimum target and a compact row cannot hold one.

web, lit, rn, swiftui
heightcontent | viewport | fixedviewport

As DataGrid: `viewport` and `fixed` both size the whole component, and the scroll region takes what the caption and status bar leave.

web, lit, rn, swiftui
loadingbooleanfalse

As DataGrid.

web, lit, rn, swiftui
showStatusBarbooleantrue

As DataGrid.

web, lit, rn, swiftui
stickyHeaderbooleantrue

As DataGrid.

web, lit, rn, swiftui
emptyMessagestring—

As DataGrid.

web, lit, rn, swiftui

Events

7 rows
EventTypeDescriptionPlatforms
onExpandChangeonExpandChange / expand-change

Fired with the new array of expanded ids (the bare array, as Tree; not wrapped in an object).

web, lit, rn, swiftui
onExpandonExpand / expand

Fired with its id (bare string) each time a row whose `children` is still `"lazy"` is expanded, so a failed load can retry; once the caller replaces `children` it never fires again for that row. It fires before the onExpandChange of the same act; `*` fires one onExpand per newly opened lazy row, then one onExpandChange, and nothing when no row opens.

web, lit, rn, swiftui
onSortChangeonSortChange / sort-change

As DataGrid.

web, lit, rn, swiftui
onSelectionChangeonSelectionChange / selection-change

As DataGrid (row ids or one cell).

web, lit, rn, swiftui
onCellChangeonCellChange / cell-change

As DataGrid.

web, lit, rn, swiftui
onEditStartonEditStart / edit-start

As DataGrid.

web, lit, rn, swiftui
onColumnResizeonColumnResize / column-resize

As DataGrid.

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: caption. Each is described in the props table above.

Implicit ARIA role: treegrid.

Anatomy

containerscrollRegiongridcaptionheaderheaderRowcolumnHeadersortButtonbodyrowrowHeaderexpandButtonindentcellcellContenteditorselectCellselectAllCellemptyStatestatusBar

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.

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

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

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

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

Escape dismisses — Escape closes it from anywhere inside.

TreeGrid keyboard support

10 rows
KeyAction
TabEnters and leaves the grid — one tab stop, as DataGrid.
ArrowDown or ArrowUpNext / previous visible row, same column. Collapsed descendants are skipped because they are not rendered.
ArrowRightOn a collapsed row header: expands. On an expanded row header: moves to the next cell. On any other cell: next cell.
ArrowLeftOn a row header of an expanded row: collapses. On a row header of a collapsed or leaf row: moves focus to the parent row's header; at level 1 (no parent) it moves to the previous cell. On any other cell: previous cell. On a loading placeholder row: moves to its parent.
Home or EndFirst / last cell in the row (Ctrl: first / last cell of the grid, the header row included, as DataGrid).
EnterOn a row header of a row with children: toggles expansion — always, never edits, even when that column is editable (F2 is the edit path for an editable parent row header). On a leaf row header: DataGrid's order (edit if editable, else activate a control inside it). Elsewhere as DataGrid: sort, edit, activate.
*Expands every row at the focused row's level under the same parent, the focused row included; a `"lazy"` sibling opens too and fires its onExpand, unlike `defaultExpanded: ["*"]`, because this is a user act.
F2 or Escape or Space or Control+a or Shift+SpaceAs DataGrid. Escape cancels an open editor and returns focus from a control inside a cell to the grid; on the grid itself it does nothing (there is no range here to clear, and no collapse-all), which is also all `escape-dismiss` asks of a component with no overlay. Control+a selects every loaded row in `row` mode and is unhandled in `cell` and `none`. Shift+Space extends the row selection from the last plain-Space anchor through the focused row, over the visible rows only — a range does not cascade into descendants even with selectChildren, which is a per-row act; when that anchor row is no longer visible (its subtree was collapsed, or sorting moved it) Shift+Space is a plain, non-cascading toggle of the focused row. Shift with the arrows keeps their own meaning here (column navigation, expand and collapse) and never selects. Control means Control or Meta, as DataGrid.
PageDown or PageUp or Delete or BackspaceAs DataGrid: Page keys move one visible page of rows; Delete/Backspace clear editable cells in the selection — in `row` mode every editable cell of every selected row, in `cell` and `none` the active cell alone. Typing a printable character opens an editor, as DataGrid (the character seeds the text and number editors only; select, date and checkbox editors open unseeded).
Shift+ArrowRight or Shift+ArrowLeftWidens / narrows the column by DataGrid's resizeStep and fires onColumnResize on release of Shift, as DataGrid; in the body Shift+arrows keep their navigation meaning. (focus on the header cell of a resizable column)

Contrast pairs

2 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foreground.mutedcolor.backgroundAA

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