DataGrid
A spreadsheet-like grid — one tab stop, arrow keys between cells, Enter to edit, row and range selection, virtualized rows — for data people work in rather than read. Table's `role=grid` sibling, sharing its column and data model.
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 { DataGrid } from '@design-schema/react'; Examples
import { DataGrid, type DataGridColumn, type DataGridRow } from '@design-schema/react';
const columns: DataGridColumn[] = [
{ key: 'sku', header: 'SKU', isRowHeader: true, width: 160, pinned: 'start' },
{ key: 'name', header: 'Name', sortable: true, resizable: true },
{ key: 'category', header: 'Category', sortable: true },
{ key: 'price', header: 'Price (USD)', abbr: 'Price in US dollars', align: 'end' },
{ key: 'stock', header: 'Qty', abbr: 'Quantity in stock', align: 'end' },
];
const names = ['Widget', 'Sprocket', 'Gear', 'Flange', 'Bracket', 'Hinge', 'Spring', 'Valve'];
const categories = ['Hardware', 'Fittings', 'Motion'];
const data: DataGridRow[] = Array.from({ length: 200 }, (_, index) => ({
id: `row-${index}`,
sku: `A-${1000 + index}`,
name: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
category: categories[index % categories.length],
price: ((index * 37) % 500) + 10,
stock: (index * 13) % 90,
}));
<DataGrid caption="Price list" columns={columns} data={data} />Props
17 rows| Prop | Type | Default | Description |
|---|---|---|---|
| caption required | string | — | What the grid holds ("Price list"). The accessible name; visually hidden with `hideCaption`. Required: a grid built without one falls back to an empty caption and warns in development. Accessibility: aria-labelledby the caption (web) / accessibilityLabel (native). |
| captionLevel | 2 | 3 | 4 | 2 | Heading level of the caption in the page outline; its size is captionSize regardless, as Table. On React Native the level has no observable effect — Heading sets the header trait at every level and captionSize/captionWeight pin the typography — so the prop is accepted and forwarded for parity only. |
| hideCaption | boolean | false | Visually hide the caption; it remains the accessible name. |
| columns required | array { key: string; header: string; abbr?: string; align?: "start" | "end" | "center"; sortable?: boolean; width?: number; minWidth?: number; resizable?: boolean; isRowHeader?: boolean; pinned?: "start" | "end"; editable?: boolean; editor?: "text" | "number" | "select" | "date" | "checkbox"; options?: { value: string; label: string }[]; render?: (row: Row) => unknown; validate?: (value: unknown, row: Row) => string | undefined }[] | — | Table's column model plus grid concerns: pixel `width` (columns do not auto-size in a virtualized grid; `width` is a multiple of space.1, e.g. 160; the `columnWidth` binding when omitted), `minWidth` (the floor for pointer and keyboard resize; never below size.target.min, which is also its default), `resizable` (pointer drag on the header edge, Shift+ArrowLeft/Right on the header cell by `resizeStep`), `pinned` columns that stay put while scrolling sideways (pinned columns must be contiguous at the start or end of `columns` and keep their order there; the selection column is always pinned start and counted in the offsets), `editable` with an `editor` kind and `validate`. Exactly one column may be `isRowHeader`. `abbr` is the spoken name of a non-sortable header (the visible header is aria-hidden beside visually hidden `abbr` text); a sortable header's name stays copy.sortAscending/sortDescending built from `header`, so `abbr` has no effect on a sortable column. `render` returns a ReactNode on web and rn and a lit-renderable value (TemplateResult, string or number) on Lit — hence `unknown` in the shape, the one type that covers all three — each platform narrows it to its own renderable type in the generated signature, so the shape is the cross-platform form rather than the emitted one. The two column rules with no runtime consequence — exactly one `isRowHeader`, pinned columns contiguous at one end — are development-time warnings on every platform, never thrown errors. |
| data required | array Row[] where Row = { id: string; [key: string]: unknown } | — | The rows. `id` must be stable. Large arrays are fine; only visible rows are rendered. |
| rowCount | number | — | Total rows when `data` is a window of a larger set (server paging). Sets aria-rowcount; `onRangeNeeded` asks for more. `data` is always a contiguous prefix of the full set starting at row 0 and only grows by appending; keyboard navigation is clamped to loaded rows. |
| sort | object { column: string; direction: "ascending" | "descending" } | — | Controlled sort state; as Table. |
| defaultSort | object { column: string; direction: "ascending" | "descending" } | — | Initial sort; the grid sorts `data` itself when `rowCount` is not set and the sort is uncontrolled. A controlled `sort` never reorders `data` — the caller owns the order, as Table. |
| selectable | none | row | cell | range | none | `row` adds a checkbox column and Shift/Ctrl row selection (pointer: a click on the select cell or its Checkbox toggles the row, Ctrl/Cmd+click toggles, Shift+click adds the rows from the anchor through the clicked row, a plain click on a data cell only moves focus); `cell` selects the focused cell (selection follows focus, no key needed, and the focus ring is its only visual: onSelectionChange fires on every move to a body data cell, whether the move came from the keyboard or from a click, and a move to a header cell or to a row that is not loaded selects nothing); `range` allows Shift+arrow / pointer-drag rectangles (copy as TSV). Selection is separate from focus in row and range mode: focus is where the keyboard is, selection is what an action applies to. |
| selected | array string[] | — | Controlled selected row ids (row mode). There is no `defaultSelected`: left undefined the grid keeps the selection itself, and onSelectionChange fires in both modes, as the controlled-state rule for `sort`. |
| editable | boolean | false | Master switch: cells whose column is `editable` can be edited with Enter, F2, typing, or double-click. Off by default so a grid is read-only unless meant to be edited. |
| density | compact | comfortable | compact | Row height: compact suits the grid's purpose; comfortable for touch. A grid with a selection column is comfortable at both values, as rowHeight explains. |
| stickyHeader | boolean | true | The header stays visible while the grid's own scroll region scrolls. Always true when virtualized. It never sticks to the page, so with `height: content` (the page scrolls) it has no effect. Every `height` but `content` virtualizes, so between the two rules the prop changes nothing anywhere: it is accepted for parity and for a future unvirtualized mode, and `false` is neither honoured nor warned about. |
| height | content | viewport | fixed | viewport | `viewport` sets the whole component (caption, scroll region and status bar) to `100vh − 2 × layout.gap.section` — on native, the window height less 2 × layout.gap.section — and the scroll region takes what the caption and status bar leave, as Table (the grid, not the page, scrolls); `content` grows with rows (no virtualization, small grids); `fixed` uses a height the caller sets through `overrides.fixedHeight` — one of the few size bindings that is overridable by design. |
| loading | boolean | false | Sets aria-busy and shows `copy.loading` in the status bar; existing rows stay, their text in cellMutedColor. The empty state is suppressed while `loading`, so a grid loading its first page shows copy.loading in the status bar and no empty message; `emptyMessage`/`copy.empty` appear once `loading` is false and `data` is still empty. |
| emptyMessage | string | — | Shown when `data` is empty. |
| showStatusBar | boolean | true | A footer line with row count, selection count and, while editing, the validation message — the visible counterpart of the live region. |
Events
6 rows| Event | Type | Description |
|---|---|---|
| onSortChange | onSortChange / sort-change | As Table. |
| onSelectionChange | onSelectionChange / selection-change | Fired with the selection: row ids, one cell `{ rowId, column }`, or a range `{ from, to }`. Fired only when the selection actually changes; clearing a range with Escape fires nothing (the union has no empty range), and a plain arrow that collapses a multi-cell range fires once with the one-cell range. |
| onCellChange | onCellChange / cell-change | Fired when an edit commits, with `{ rowId, column, value, previous }`, and only when the committed value differs from the cell's (Object.is); `validate` still runs on an unchanged commit. The caller updates `data`; the grid shows the old value until it does (a rejected edit reverts visibly). |
| onEditStart | onEditStart / edit-start | Fired when an editor opens; return false (web, rn — the payload is positional, so there is no event to preventDefault) or call preventDefault (lit) to refuse editing that cell. |
| onRangeNeeded | onRangeNeeded / range-needed / onEndReached | Fired when the visible window (or Ctrl+End / PageDown) comes within one page of the end of `data` and `rowCount` says there is more, with `{ start, end }` row indexes to load; fired once per `end` until `data` grows. It asks for one visible page: `start = data.length`, `end = min(rowCount − 1, data.length + rowsPerPage − 1)`, where rowsPerPage is `floor(the scroll region's height ÷ the row height) − 1`, the sticky header's row left out so a paged request keeps one row of context (on rn, `onEndReachedThreshold={1}`). Checked on scroll and keyboard moves only, never on mount — on rn, where the list reports the end at layout, a request is suppressed until the list has scrolled once, so a first page shorter than the region waits for a scroll rather than paging on mount. |
| onColumnResize | onColumnResize / column-resize | Fired with `{ column, width }` when the user finishes dragging a resizable column edge, or on keyup of Shift after Shift+ArrowLeft/Right resizing. |
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: grid.
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.
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).
No hover-only content — Nothing is reachable by hover alone; keyboard focus reveals the same content.
Escape dismisses — Escape closes it from anywhere inside.
This component does not animate, so it has no reduced-motion behaviour to describe.