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

Price list

SKU
Price in US dollars
Quantity in stock
A-1000
Widget 1
Hardware
10
0
A-1001
Sprocket 1
Fittings
47
13
A-1002
Gear 1
Motion
84
26
A-1003
Flange 1
Hardware
121
39
A-1004
Bracket 1
Fittings
158
52
A-1005
Hinge 1
Motion
195
65
A-1006
Spring 1
Hardware
232
78
A-1007
Valve 1
Fittings
269
1
A-1008
Widget 2
Motion
306
14
A-1009
Sprocket 2
Hardware
343
27
A-1010
Gear 2
Fittings
380
40
A-1011
Flange 2
Motion
417
53
A-1012
Bracket 2
Hardware
454
66
A-1013
Hinge 2
Fittings
491
79
A-1014
Spring 2
Motion
28
2
A-1015
Valve 2
Hardware
65
15
A-1016
Widget 3
Fittings
102
28
A-1017
Sprocket 3
Motion
139
41
A-1018
Gear 3
Hardware
176
54
A-1019
Flange 3
Fittings
213
67
A-1020
Bracket 3
Motion
250
80
A-1021
Hinge 3
Hardware
287
3
A-1022
Spring 3
Fittings
324
16
A-1023
Valve 3
Motion
361
29
A-1024
Widget 4
Hardware
398
42
A-1025
Sprocket 4
Fittings
435
55
A-1026
Gear 4
Motion
472
68
A-1027
Flange 4
Hardware
509
81
A-1028
Bracket 4
Fittings
46
4
A-1029
Hinge 4
Motion
83
17
A-1030
Spring 4
Hardware
120
30
A-1031
Valve 4
Fittings
157
43
A-1032
Widget 5
Motion
194
56
A-1033
Sprocket 5
Hardware
231
69
A-1034
Gear 5
Fittings
268
82
A-1035
Flange 5
Motion
305
5
A-1036
Bracket 5
Hardware
342
18
A-1037
Hinge 5
Fittings
379
31
A-1038
Spring 5
Motion
416
44
A-1039
Valve 5
Hardware
453
57
A-1040
Widget 6
Fittings
490
70
A-1041
Sprocket 6
Motion
27
83
A-1042
Gear 6
Hardware
64
6
A-1043
Flange 6
Fittings
101
19
A-1044
Bracket 6
Motion
138
32
A-1045
Hinge 6
Hardware
175
45
A-1046
Spring 6
Fittings
212
58
A-1047
Valve 6
Motion
249
71
A-1048
Widget 7
Hardware
286
84
A-1049
Sprocket 7
Fittings
323
7
200 rows
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
PropTypeDefaultDescriptionPlatforms
caption requiredstring—

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

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

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.

web, lit, rn, swiftui
hideCaptionbooleanfalse

Visually hide the caption; it remains the accessible name.

web, lit, rn, swiftui
columns requiredarray { 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.

web, lit, rn, swiftui
data requiredarray Row[] where Row = { id: string; [key: string]: unknown }—

The rows. `id` must be stable. Large arrays are fine; only visible rows are rendered.

web, lit, rn, swiftui
rowCountnumber—

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.

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

Controlled sort state; as Table.

web, lit, rn, swiftui
defaultSortobject { 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.

web, lit, rn, swiftui
selectablenone | row | cell | rangenone

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

web, lit, rn, swiftui
selectedarray 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`.

web, lit, rn, swiftui
editablebooleanfalse

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.

web, lit, rn, swiftui
densitycompact | comfortablecompact

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.

web, lit, rn, swiftui
stickyHeaderbooleantrue

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.

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

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

web, lit, rn, swiftui
loadingbooleanfalse

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.

web, lit, rn, swiftui
emptyMessagestring—

Shown when `data` is empty.

web, lit, rn, swiftui
showStatusBarbooleantrue

A footer line with row count, selection count and, while editing, the validation message — the visible counterpart of the live region.

web, lit, rn, swiftui

Events

6 rows
EventTypeDescriptionPlatforms
onSortChangeonSortChange / sort-change

As Table.

web, lit, rn, swiftui
onSelectionChangeonSelectionChange / 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.

web, lit, rn, swiftui
onCellChangeonCellChange / 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).

web, lit, rn, swiftui
onEditStartonEditStart / 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.

web, lit, rn, swiftui
onRangeNeededonRangeNeeded / 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.

web, lit, rn, swiftui
onColumnResizeonColumnResize / 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.

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: grid.

Anatomy

containerscrollRegiongridcaptionheaderheaderRowcolumnHeadersortButtonresizeHandlebodyrowrowHeadercellcellContenteditorselectCellselectAllCellrangeOverlayemptyStatestatusBar

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.

DataGrid keyboard support

18 rows
KeyAction
TabEnters the grid on the last-focused cell (initially the first header cell) and, from inside, leaves it — the grid is one tab stop. Inside a cell that contains a control, Tab still leaves the grid; use Enter to interact with the control. While an editor is open, Tab commits and opens the next editable cell in the row (Shift+Tab the previous); from the last editable cell it commits and leaves the grid — the editor is removed in the same task so the browser's own Tab lands outside, unless `validate` rejects that commit, in which case the editor stays open and the key does nothing.
ArrowRightNext cell in the row.
ArrowLeftPrevious cell.
ArrowDownSame column, next row (into the body from the header).
ArrowUpSame column, previous row (into the header from the first body row).
HomeFirst cell in the row (Ctrl: first cell in the grid, the header row included).
EndLast cell in the row (Ctrl: last cell in the grid).
PageDownDown one visible page of rows, same column.
PageUpUp one visible page of rows.
EnterOn a header cell: sorts (if sortable). On an editable cell: opens the editor; while editing, commits and moves focus down one row. On the selection cell: toggles its checkbox. On a cell whose `render` contains a control (Link, Button, Checkbox) and whose column is not `editable` — an editable column opens its editor instead, the editor always winning over the control: focuses the first focusable descendant and clicks it (on a custom element, its inner native control), and Escape hands focus back to the cell, which on web and Lit means the grid element with aria-activedescendant left on that cell, cells never being real focus targets. A control here is a link, button, input, select, textarea, any element carrying a tabindex, or a composed Link, Button or Checkbox; anything outside that set is neither reached by Enter nor demoted, so do not render one. Controls inside `render` are demoted to tabindex=-1 after every update so the grid stays one tab stop.
F2Opens the editor without moving; while editing, commits and returns focus to the cell without moving.
EscapeWhile editing, cancels the edit and restores the value. Otherwise clears a range selection. With no editor open and no range, the key is left unhandled and bubbles, so an overlay the grid sits in still closes.
SpaceRow mode: toggles the focused row; Shift+Space adds the rows from the anchor (the last row toggled) through the focused row to the existing selection. Range mode: selects the focused row as a full-width range, Shift+Space extends that row range, Ctrl+Space selects the focused column as a range over the loaded rows. (selectable is row or range)
Shift+ArrowRight or Shift+ArrowLeft or Shift+ArrowDown or Shift+ArrowUpExtends the range selection from the anchor; a plain navigation key (arrows, Home/End, Page keys) collapses the range to the newly focused cell and makes it the anchor. After Escape has cleared the range, plain keys only move the anchor. (selectable is range and focus in the body)
Shift+ArrowRight or Shift+ArrowLeftWidens / narrows the column by resizeStep and fires onColumnResize on release of Shift. (focus on the header cell of a resizable column)
Control+aSelects all loaded rows or cells (bound by key code KeyA, so it works on any layout). Control here and in every Ctrl chord of this table means Control or Meta (Cmd on macOS). (selectable is row or range)
Control+cCopies the selection as tab-separated text; the header line is written when the range spans every loaded row, which is what "whole columns" means for a partly loaded set. Bound by key code KeyC, Control or Meta. copy.copied is announced once the clipboard write resolves; a refused or unavailable clipboard announces nothing. (selectable is range)
Delete or BackspaceClears the value of editable cells in the selection — every editable cell of the selected rows (row), the range (range), the focused cell (cell); nothing in `none` mode: onCellChange fires once per cleared cell with `value: undefined`, the same shape the column model calls an omitted value, not an empty string, in row-major order (rows in display order, columns in `columns` order). `validate` does not run; the caller rejects a clear by not updating `data`. These two keys are the only path to `undefined` besides an emptied number editor, so on rn — where they have no equivalent — a cell cannot be cleared at all; clearing is web, lit and swiftui. (editable and selection)

Contrast pairs

11 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foreground.mutedcolor.backgroundAA
color.foregroundcolor.background.subtleAA
color.foreground.mutedcolor.background.subtleAA
color.foregroundcolor.background.strongAA
color.foregroundcolor.control.backgroundAA
color.status.danger.foregroundcolor.status.danger.backgroundAA
color.control.selectedBackgroundcolor.backgroundAA (non-text)
color.control.selectedBackgroundcolor.background.subtleAA (non-text)
color.border.focuscolor.control.backgroundAA (non-text)
color.border.dangercolor.backgroundAA (non-text)

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