Data Grid
The full-parity grid — multi-key sort, frozen and resizable columns, grouping, row and column virtualization, a summary row, and APG cell editing.
- Status
- Since
0.4.0- Accessibility pattern
- APG grid
Last updated
| Globex expansion | Mel | $48,000 | ||
| Umbrella upsell | Priya | $22,000 | ||
| Northwind seats | Ada | $15,600 | ||
| Acme renewal | Priya | $12,400 | ||
| Initech pilot | Ada | $9,800 |
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Data Grid before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify data-gridInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/data-gridRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/auto-save-input, @vegastack/button, @vegastack/context-menu, @vegastack/cursor-pager, @vegastack/data-table-parts, @vegastack/dropdown-menu, @vegastack/editable-cell, @vegastack/grid-clipboard, @vegastack/load-more, @vegastack/skeleton, @vegastack/table, @vegastack/truncated-text, @vegastack/use-announcer, @vegastack/use-drag-reorder, @vegastack/use-grid-selection, @vegastack/checkbox, @vegastack/empty, @vegastack/action-bar, @vegastack/floating-cell-editor.
It also adds the sanctioned engines to your package.json: @tanstack/react-table (sorted row-model engine (client sort mode only)), @tanstack/react-virtual (row and column windowing/measurement engine).
Usage
Virtualized keyboard navigation scrolls an offscreen target into view and transfers focus when its cell mounts.
import { DataGrid } from "@/components/ui/data-grid";
<DataGrid
aria-label="Deals"
columns={[
{ key: "name", header: "Name", sortable: true, mobile: "visible" },
{
key: "stage",
header: "Stage",
editable: { type: "select", options: stages },
},
{
key: "amount",
header: "Amount",
align: "end",
mobile: "merge",
render: (d) => <span className="tabular-nums">{d.amount}</span>,
},
]}
data={deals}
getRowId={(d) => d.id}
onCellCommit={(row, key, value) => api.patch(row.id, { [key]: value })}
selectable
/>;DataGrid is the commissioned full-parity sibling
DataList always pointed to. The sorted row
model comes from TanStack Table (the sanctioned engine); row and column
windowing from TanStack Virtual behind the virtualize and virtualizeColumns
flags; the APG grid keyboard layer — which no library ships — is the
component's own.
// Spreadsheet scale: the host sorts and groups on the server; both axes are windowed.
<DataGrid
aria-label="Sample requests"
columns={fields} // { key, header, width, frozen, headerMenu, summaryOptions, … }
data={rows}
getRowId={(row) => row.id}
sortMode="manual"
sort={sort}
onSortChange={setSort}
onColumnResize={saveWidth}
onColumnReorder={saveOrder}
columnOrder={order}
virtualize
virtualizeColumns
rowHeight="short"
maxHeight="70dvh"
ariaRowCount={total + 1}
headerTrailing={(focus) => <AddFieldButton {...focus} />}
summaryRow={{
label: `${total} rows`,
value: fns,
onValueChange: setFn,
results,
}}
cellMenu={(rowId, key) => <CellMenuItems rowId={rowId} field={key} />}
/>Two deliberate differences from DataList, for migrators:
- Cells render as component elements, so a
renderimplementation may use hooks (DataListinvokesrenderas a plain function and cannot allow that). - The grid sorts its own data — that is what the row-model engine is for.
sort/onSortChangestay controllable for URL state. PasssortwithoutonSortChangewhen a toolbar owns the sort: headers stop being sort buttons, and each sorted column shows a read-only ↑/↓ (with its ordinal in a multi-sort) andaria-sort.
Scope
Reversed in 0.25.69 (Regent Tables, R2): the grid used to leave out column resize, a reorder affordance, aggregation footers, and grouped virtualization. It now hosts a 50,000-row, 150-column, server-sorted grid — so frozen and resizable columns, header drag-to-reorder, a summary row, host-sorted rows, host groups and column windowing are in. It still computes nothing the host should: summary values, server sort orders and group pages come in as props.
| Behaviour | Where it lives |
|---|---|
| Data fetching, filters, saved views | Host — FilterBuilder + the toolbar slot |
| The cell write + conflict handling | Host — onCellCommit is a request; cellStatus is your word |
| Pagination UI | The footer slot; loadMore covers the continuous case |
| Simple presentational tables | DataList — still the right default |
| The shared table chrome | @vegastack/data-table-parts — sort header, selection, async states; it arrives with this component (shared internals) |
Two layouts
| Layout | Turns on when | Columns |
|---|---|---|
| Auto (default) | — | Fill and fit, like DataList; responsive revelation merges or drops columns on narrow screens. |
| Fixed | Any column has width, frozen or resizable, or the grid has onColumnResize or virtualizeColumns | table-layout: fixed from a <colgroup>; every cell one line (truncating); the grid scrolls sideways instead of revealing; frozen columns stick to the inline start. |
The grid renders its own <table> inside ONE scroll viewport (data-slot="grid-viewport"), so the
sticky header, the sticky summary row and the frozen columns all resolve against the same box.
The fixed layout fills its box: the table is never narrower than the viewport, and a last,
unsized filler track takes whatever the columns leave. The header band, the row borders and
washes, the summary row and the footer reach the viewport's end while every column keeps its own
width, and the headerTrailing "+" column stays right after the last real column.
On a phone-width container (under 40rem) phoneLayout="cards" shows each row as a card: the
primary value as its title (it opens the row, with onRowOpen) and the next cardFields columns
(3 by default) as labelled values. The grid decides by its own width, so a tablet keeps the grid
and a narrow pane gets cards. A grid with a summaryRow shows it above the cards as one read-only
line — its label ("412 rows"), then "Amount · Sum 12,400" for each summarised column — wrapping on
a narrow screen; the functions are chosen from the table's footer.
The toolbar
The toolbar row holds the host's toolbar slot on the leading edge and the grid's own controls on
the trailing edge: the hidden-columns hint, then the "Columns" picker.
The picker is on by default and turns off with columnPicker={false} — a fixed three-column grid
should not carry a column manager, and a host that drives visibility from its own settings surface
already has one. With no toolbar, no picker and nothing hidden, the toolbar row is not rendered at
all.
Examples
Opening rows
A grid row opens its record — a peek beside the grid — without fighting cell selection or
editing. onRowOpen(rowId, { event, index }) adds an Open button (24×24) at the start of each
row's primary cell: it shows on row hover and on keyboard focus anywhere in the row (always on
touch) and holds its place at rest, so nothing shifts. From the keyboard, Space on any
of the row's cells or Enter on its primary cell (the row header) opens it;
F2 or typing still edits. activeRowId marks the open row
(data-peek-active, the accent wash) and scrolls it into view as the peek
walks; actionsRef exposes getRowOrder() for that walk and focusCell(rowId, key) to put the active cell on a new row.
The active cell is outlined (2px, inset) while the grid holds focus, whether a click or a key put
it there; a range's other cells take the tint. A header with a headerMenu is one button that opens
the menu (sort lives in it), a column's icon sits before its label, and a numeric column lines
its figures and its header up at the end. selectionActions docks the same selection bar as
DataList's while rows are checked. Narrow the preview below 40rem to see the cards.
Sample request 1 | 2 | |||
Sample request 2 | 4 | |||
Sample request 3 | 6 | |||
Sample request 4 | 8 | |||
Sample request 5 | 10 | |||
Sample request 6 | 12 | |||
Sample request 7 | 14 | |||
Sample request 8 | 16 |
Hover a row and press its Open button, or press Space on a cell.
Embedded in a page
embedded makes the grid a table block inside a page or document: one framed block with the
toolbar (a BlockHeader) on top and the footer under the
rows. Its height comes from its rows — at most maxRows (25 by default) — so it never scrolls
inside itself; it scrolls sideways when its columns overflow, with no cap on how many show. While
more columns sit past the end, a quiet edge shade marks the trailing side (any grid that
overflows sideways shows it), and a sideways pan stops at the grid's edge instead of moving the
page. The
built-in column picker is off (the block header's View menu owns the fields). Rows open exactly as
they do on a table page (onRowOpen), and cells edit in place.
footer takes an object for a block: { onNewRow, shown, total } puts + New row at the start
and "25 of 117" at the end (only while rows are left out). readOnly is for people who can see the
table but not change it: no cell editors, paste, clear, fill or undo keys, no add-field column and
no + New row — rows still open, sort and select. Turn Can edit off in the preview.
Name | Status | Qty | Owner | Site |
|---|---|---|---|---|
Sample request 1 | 2 | Priya | Pune | |
Sample request 2 | 4 | Mel | Leeds | |
Sample request 3 | 6 | Ada | Austin | |
Sample request 4 | 8 | Priya | Pune | |
Sample request 5 | 10 | Mel | Leeds |
Empty state
An empty grid wider than its box holds its empty (or error) state to the viewport — centred where
the reader is, never clipped by the columns scrolled out of view. loading draws loadingRows
skeleton rows (5 by default): pass the rows the page will hold so the loaded grid lands without a
shift.
Name | Field 1 | Field 2 | Field 3 | Field 4 | Field 5 | Field 6 | Field 7 | Field 8 | |||
|---|---|---|---|---|---|---|---|---|---|---|---|
Grouping
One group column groups the rows by value on the client. Groups are flattened into the one row
list: a group header is a row of its own, followed by its rows, so grouping and virtualize
combine. The group header is the shared SectionToggle DataList's sections use: the row count sits
after the label in muted tabular numerals (no parentheses), and a screen reader hears it as "Open,
2 rows". The header rows are on the keyboard path: ↑/↓ stop on them and
Enter or Space toggles.
Stage | ||
|---|---|---|
| Acme renewal | Qualified | $12,400 |
| Initech pilot | Qualified | $9,800 |
| Globex expansion | Proposal | $48,000 |
| Northwind seats | Proposal | $15,600 |
| Umbrella upsell | Won | $22,000 |
Spreadsheet scale
150 columns × 50,000 rows: a frozen primary column, resizable columns (drag the edge, double-click to
fit, or Alt+←/→ on a focused header), both axes windowed, fixed
short rows, and a sticky summary row whose values the host computes. The grid renders the frozen
cells, two spacer cells and the columns in view — never 150 cells a row.
Name |
|---|
| 50,000 rows |
For the 8 ms scroll budget the structure is fixed: one scroll box, fixed row heights, memoised rows
and cells. Keep columns and cellStatus stable (useMemo, useCallback) and give a changed row a
new object — a cell re-renders only when its row object or its column definition changes.
Host groups
sections hands the grid the server's groups: each names its rows (rowIds), its total count,
and its own loadMore, so every expanded group pages its own keyset. The grid flattens them into
the one virtual list — group header, the group's loaded rows, then the group's load-more row while it
has more.
Name | Owner | Amount |
|---|
Paged mode
pagedMode turns row windowing off and puts a CursorPager
(Previous / Next, no page numbers) under the grid, for people who page instead of scroll. Column
windowing stays on. ariaRowIndexOffset keeps the row numbers true across pages.
Name | Stage | Amount |
|---|---|---|
| Deal 1 | Qualified | $1,000 |
| Deal 2 | Proposal | $2,000 |
| Deal 3 | Won | $3,000 |
| Deal 4 | Qualified | $4,000 |
| Deal 5 | Proposal | $5,000 |
Cells mode
selectionMode="cells" makes the grid a spreadsheet surface. A range is kept by row id and column
key (use-grid-selection), so it survives a refetch and windowing; it covers loaded rows only.
- Select: Shift+arrows, Shift+click or a drag extend the range; Shift+Space takes the row, Ctrl+ Space the column, ⌘/Ctrl+A everything; a plain move or Escape collapses it. A range of more than one cell is announced ("4 by 3 cells selected").
- Copy, cut, paste go through the
copy,cutandpasteevents (never the async Clipboard API) as TSV plus a bare HTML table, read bygrid-clipboard: Excel, Google Sheets (with its exact numbers), Numbers and Airtable. One pasted value fills the range; a pattern that divides it tiles. Each column'sparse(cell, row)turns a pasted cell into its value andformat(row)writes its copy text. A column with neitherparsenoreditableis read-only to paste, clear and fill. Rows that run past the last loaded row go toonPasteOverflow— the host asks "Add 38 rows?". A paste into an open editor is the editor's. - Write: a paste, a clear (Delete) or a cut is ONE
onCellsCommit(changes)batch; the host answers per cell (ok,conflict,invalid,stale_schema) and the grid announces "Pasted 120 cells · 4 skipped". - Fill: drag the handle on the range's corner up or down, or ⌘/Ctrl+D
to fill down;
onFill({ source, target, series })— Alt while dragging asks for a series. The host computes the values. - Undo ⌘/Ctrl+Z and redo ⇧⌘Z / Ctrl+Y
reach
onUndo/onRedo; the host owns the history. - Someone else's change:
remoteChanges(keyed bygridCellKey(rowId, key)) pulses the cell with the system's highlight-on-change ring (motion-flash, gone under reduced motion) and shows the author in its corner for 1.5s; only the focused cell'sannouncementis spoken.
Name | Status | Qty |
|---|---|---|
| Sample request 1 | 2 | |
| Sample request 2 | 4 | |
| Sample request 3 | 6 | |
| Sample request 4 | 8 | |
| Sample request 5 | 10 | |
| Sample request 6 | 12 | |
| Sample request 7 | 14 | |
| Sample request 8 | 16 |
Inline editing
Editable columns open EditableCell in
focusMode="managed" — the grid owns reachability, the cell owns the editor
and its async status. Enter/F2 edits keeping the value;
typing a character edits replacing it (text and number editors); Enter
commits and moves down, Tab/Shift+Tab commit and
move across; Escape restores grid navigation; a rejected commit
reverts and announces. While an IME is composing, no key commits or moves.
An editable column's render is its display while the cell holds a non-empty
value and is not being edited (tags, chips, avatars); an empty cell is blank —
the editor's placeholder shows only once it edits — and a pending commit shows
its value as text until the host's updated row arrives. Without a render the editor's own display shows
(a select's label, a formatted date or number). A custom editor's render
receives the row with value, seed, commit and cancel, so it can read the typed
value the cell's string can't carry: seed is the key that opened it (type-to-edit),
and commit(next, move) with "down", "up", "right" or "left" steps the grid on
the way Enter and Tab do.
Name | Stage |
|---|---|
| Acme renewal | |
| Globex expansion | |
| Initech pilot | |
| Umbrella upsell | |
| Northwind seats |
Focus a cell, press Enter or F2 to edit; Escape restores grid navigation. Commits are async — watch the cell status.
Floating editors
editing="floating" opens a custom editor over its cell instead of inside it: the cell keeps
drawing its resting face, and the editor — anchored at the cell, reaching 2px past it, at least as
tall as the cell — floats on a FloatingCellEditor with
the page locked behind it. The column's editable.floating sets the editor's size (text-like
types text, dates wide, pickers picker) and dismissOnPress, for an editor that saves when
something is chosen and so needs a press outside to close it. The built-in text, number, date and
select editors stay inline.
<DataGrid
editing="floating"
columns={[
{
id: "notes",
editable: {
type: "custom",
floating: { size: "text" },
render: ({ value, commit, cancel }) => (
<FieldCellEditor
variant="cell"
type="text"
label="Notes"
value={value}
onCommit={commit}
onCancel={cancel}
/>
),
},
},
]}
/>Filling the page
fill makes the viewport as tall as the room from its top edge to the bottom of its nearest
scrolling ancestor, so the grid's scroll bar rests at the bottom of the window even when the rows
are few; it follows the window and anything above the grid that comes, goes or resizes, and is
never under 240px. A maxHeight of its own wins. Inside an AppShellPage, wrap the grid in
AppShellBleed so it reaches the page's side edges and bottom.
endSpacer leaves a band (180px, or the pixels given) after the last row so the last rows scroll
clear of anything floating at the bottom, columnDividers rules every column's end (the frozen
columns too, drawn over their sticky background) and editingTint fills the cell being edited with
the accent tint.
<AppShellPage size="full">
<PageHeader title="Schedule" />
<AppShellBleed>
<DataGrid fill endSpacer columnDividers editingTint headerAlign="start" … />
</AppShellBleed>
</AppShellPage>headerAlign="start" (or a column's own headerAlign) starts a numeric column's header at the
start — icon, name, sort arrow, menu chevron in that order — while its values and its summary stay
end-aligned.
Keyboard-continuous load-more
The next page loads as the grid's end nears the edge of its scroll viewport, and
↓ past the last row fetches it too — paging never breaks the keyboard
flow, and the two share one guard, so a page is requested once. While it loads,
skeleton rows the height of real ones are appended to the body. The tail is the
shared LoadMore: no button, Try again after a
failed fetch (loadMore.error), and nothing once hasMore is false unless you
pass loadMore.endLabel.
Name | Stage |
|---|---|
| Acme renewal | Qualified |
| Globex expansion | Proposal |
| Initech pilot | Qualified |
Responsive revelation
Columns declare a pixel budget (minWidth, default 120) and a posture for what happens when they no
longer fit. Data is never silently lost when a viewport narrows, so the default posture is
merge:
mobile | When the column no longer fits |
|---|---|
"merge" | Default. Stacks into the primary (first) column's cell, under its main value. |
"visible" | Never hides — the column is essential and the grid scrolls instead. |
"hidden" | Drops the column, and the toolbar reports "N columns hidden". |
"hidden" is the only posture that actually removes a value, which is why it is opt-in and why it
announces itself. If you choose it, be sure the value is genuinely recoverable elsewhere.
Fill and fit, as in DataList: the primary (first) column takes the width the others leave
and its text wraps, anywhere when a single word is wider than the cell; every other column is sized
to its content and kept on one line, and a header never breaks.
A merged value wraps inside the primary cell — it does not inherit a mono or end-aligned
primary column's one-line posture, and it wears its own column's face. When a sorted column is
merged or hidden (or switched off in the column picker), its header and arrow go with it, so the
toolbar states the order instead — Sorted by Stage, descending, then Amount, ascending — and the
grid is described by that line and by the hidden-columns count.
Deal | Stage | Amount | Owner |
|---|---|---|---|
| Acme renewal | Qualified | $12,400 | Priya |
| Globex expansion | Proposal | $48,000 | Mel |
| Initech pilot | Qualified | $9,800 | Ada |
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
columns* | DataGridColumn<T>[] | — | Column definitions, left to right (base order; see columnOrder). Keep it memoised. |
data* | T[] | — | Row data — every loaded row (with sections, every loaded row of every group). |
getRowId* | (row: T) => string | — | Stable, unique row id — focus, selection and edit identity. |
actionsRef | React.Ref<DataGridActions> | — | The grid's imperative actions: focusCell(rowId, key) moves the active cell (a new row's
primary cell, as soon as the row is in data), and getRowOrder() returns the loaded row ids
in display order, as a peek's ↑/↓ walk them. |
activeRowId | string | null | — | The row whose record is open beside the grid (a peek): it carries data-peek-active, takes
the accent wash and scrolls into view when it changes. |
aria-label | string | "Data grid" | Accessible name for the grid. |
ariaColCount | number | the visible column count (plus the selection column) | The true column count when the host hides some columns from the grid altogether
(aria-colcount). |
ariaRowCount | number | — | The TRUE row count, header (and summary) rows included, when only some rows are loaded
(aria-rowcount). Omit to count the rendered rows (or -1 while more load). |
ariaRowIndexOffset | number | 0 | Rows that come before the first row of data — in pagedMode, the rows of earlier pages —
so aria-rowindex stays true ("row 112 of 50,001"). |
cardFields | number | 3 | phoneLayout="cards": how many columns after the primary each card shows. |
cellMenu | ((rowId: string, key: string) => React.ReactNode) | — | Items for a body cell's context menu (ContextMenuItems), opened by a right
click, a long press, or <kbd>Shift</kbd>+<kbd>F10</kbd> / the menu key on
the focused cell. Return null for a cell without one. |
cellStatus | ((rowId: string, key: string) => AutoSaveStatus) | — | The host's word on a cell write in flight (AutoSaveStatus). Omit to let
each cell derive status from the onCellCommit promise. Keep it memoised. |
className | string | — | Extra classes for the root. |
clearSelectionLabel | string | "Clear selection" | The selection bar's Clear button, as a screen reader hears it. |
columnDividers | boolean | false | A 1px line at the end of every column, including the frozen ones — a frozen cell's own border would hide under its sticky background once the grid scrolls, so those draw the line over it — and under the last frozen column's header, the only header with one. |
columnOrder | string[] | — | Column order (array of keys), applied left to right. Controlled: the grid
applies it, and a header drag only REQUESTS a new one through
onColumnReorder. Omit to use the declared column order. |
columnPicker | boolean | true, or false when `embedded` | Render the built-in "Columns" picker in the toolbar's trailing slot. Turn it off for a grid whose columns are fixed, or when the host drives visibility from its own settings surface — a three-column read-only grid should not carry a column manager. |
columnVisibility | Record<string, boolean> | — | Controlled column visibility (key → visible). Omit for uncontrolled.
The built-in picker edits it either way. |
editing | "floating" | "inline" | "inline" | How an editing cell appears. inline — the editor replaces the cell's content. floating —
the cell keeps drawing its resting display and a custom editor opens over it
(FloatingCellEditor): anchored at the cell, reaching 2px past it, at least as tall as the
cell and as wide as the column's editable.floating.size, with the page locked behind it (a
press outside closes it; pinch-zoom still works). The built-in text, number, date and
select editors stay inline. |
editingTint | boolean | false | Fill the cell being edited with the accent tint, so an editor floating over it (or an
inline one) reads as the cell's own. |
embedded | boolean | false | A table block inside a page or document (a live table view): the grid is framed as one block
— toolbar (a BlockHeader) on top, the rows, then footer — and its height comes from its
rows, at most maxRows of them, so it never scrolls inside itself; it scrolls sideways when
its columns overflow, with no cap on how many show (the fixed layout). The column picker is
off unless columnPicker asks for it, and virtualize/maxHeight do not apply. Rows open
the same way as anywhere else (onRowOpen). |
emptyState | React.ReactNode | — | Content when data is empty and not loading. |
endSpacer | number | boolean | false | An empty band after the last row (and the load-more row), so the last rows can scroll clear of
anything floating at the bottom of the viewport. true is 180px; a number sets the pixels.
Needs a bounded viewport (maxHeight or fill). |
fill | boolean | false | Fill the room below the grid: the viewport is as tall as the distance from its top edge to the
bottom of its nearest vertically scrolling ancestor (less anything the grid draws under it),
so its scroll bar rests at the bottom of the window even when the rows are few. It follows
the window, the scroller, and anything above the grid that comes, goes or resizes (a banner,
a wrapped toolbar). At least 240px. A maxHeight of its own wins over it. Pair it with
AppShellBleed for a grid that reaches the page's edges. |
footer | DataGridBlockFooter | React.ReactNode | — | Host slot below the table — any node, or an embedded block's footer as an object
(DataGridBlockFooter): "+ New row" at the start (hidden while readOnly) and "25 of 117"
at the end. |
getRowLabel | ((row: T) => string) | "row N" | A row's name — the Open button's ("Open Alpha") and the selection checkbox's ("Select Alpha") accessible name. |
groupState | GroupState | — | Controlled collapsed state per group id. Omit for uncontrolled. |
headerAlign | "column" | "start" | "column" — each column's `headerAlign`, else its `align` | start — every column's header starts at the start, whatever its align (a column's own
headerAlign still wins). The values and the summary row stay where align puts them. |
headerTrailing | ((focus: DataGridFocusProps) => React.ReactNode) | — | A trailing header cell after the last column — an "add field" button. A render function: spread the focus props onto the one control it renders, so the control joins the header row's roving focus instead of becoming a second tab stop. |
loading | boolean | false | Show skeleton rows instead of data. |
loadingRows | number | 5 | How many skeleton rows loading shows — pass the rows the page will hold (its count, capped
at a screenful) so the loaded grid lands without a shift. |
loadMore | DataGridLoadMoreProps | — | Keyset paging, loaded on scroll: the shared LoadMore tail at the end of the grid's scroll
viewport requests the next cursor page as it nears the edge, the grid appends skeleton rows
while loading, and ArrowDown past the last row requests it from the keyboard. A failed page
shows its error with Try again; endLabel is shown once hasMore is false (nothing by
default). |
maxHeight | string | — | Scroll-viewport max height as a CSS length — required for virtualize,
useful alone for a sticky header. Flows to the viewport through
--data-grid-max-height. |
maxRows | number | 25 when `embedded`, otherwise no cap | The most rows the grid shows, the first ones in display order — an embedded block's cap.
Tell the reader how many there are with footer's shown and total. |
maxSortKeys | number | 2 | Maximum simultaneous sort keys. |
onCellCommit | ((row: T, key: string, value: string) => void | Promise<void>) | — | Commit an inline cell edit. Return a promise to engage the async layer; rejection reverts and announces (EditableCell's contract). |
onCellsCommit | ((changes: CellChange[]) => Promise<CellCommitResult[]>) | — | Cells mode: write a batch (a paste, a clear, a cut) and answer per cell, in order. invalid
cells are counted as skipped in the announcement. |
onColumnReorder | ((order: string[]) => void) | — | Turns on header drag-to-reorder (frozen columns stay put) and receives the
next full order of column keys. Apply it through columnOrder. |
onColumnResize | ((key: string, width: number) => void) | — | Turns on column resizing (fixed layout): the header's handle, its
keyboard steps (<kbd>Alt</kbd>+<kbd>←</kbd>/<kbd>→</kbd> on a focused
header), and double-click to fit. Called with the final width; the grid
shows the new width at once and the host persists it into column.width. |
onColumnVisibilityChange | ((visibility: Record<string, boolean>) => void) | — | Fired when the picker (or host) changes visibility. |
onFill | ((fill: DataGridFill) => void) | — | Cells mode: the fill handle (drag down or up) and <kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>D</kbd>. The host computes the values. |
onGroupStateChange | ((state: GroupState) => void) | — | Fired when a group toggles. |
onPasteOverflow | ((overflow: DataGridPasteOverflow) => void) | — | Cells mode: a paste ran past the last loaded row. The host decides ("Add 38 rows?"). |
onRedo | (() => void) | — | Cells mode: <kbd>⇧⌘Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd>. |
onRowOpen | ((rowId: string, details: RowOpenDetails) => void) | — | Open a row's record — a peek beside the grid. A cell click still only selects it and a double-click (or Enter, F2, typing) on a value edits it; the row opens from an **Open** button (24×24) at the start of its primary cell, shown on row hover and keyboard focus (always on touch), from <kbd>Space</kbd> on any of its cells, or <kbd>Enter</kbd> on its primary cell (the row header; <kbd>F2</kbd> still edits that one). Receives the row's id and its index among the loaded rows in display order. |
onSelectionChange | ((selectedIds: Set<string>) => void) | — | Selection change. |
onSortChange | ((sort: DataGridSort[]) => void) | — | Fired with the next sort array on header activation. |
onUndo | (() => void) | — | Cells mode: <kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>Z</kbd>. The host owns the history. |
pagedMode | CursorPagerProps | — | Paged mode, for people who page instead of scroll (a screen-reader
setting): row windowing turns off, the host shows one page of rows, and a
CursorPager (Previous / Next, no page numbers) renders under the grid.
Column windowing stays on. |
phoneLayout | "cards" | "scroll" | "scroll" | What the grid does on a phone-width container (under 40rem): scroll keeps the grid and
scrolls it sideways; cards shows each row as a card — its primary value as the title (it
opens the row, with onRowOpen) and the next cardFields columns as labelled values. The
grid decides by its own width, so a narrow pane gets cards too and a tablet keeps the grid. |
readOnly | boolean | false | Nothing in the grid edits: no cell editors, paste, clear, cut or fill, no undo keys, no
headerTrailing ("add field") column and no "+ New row" in a block footer. Rows still open,
sort and select. For people who can see a table but not change it. |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the root (data-slot="data-grid"). |
remoteChanges | ReadonlyMap<string, DataGridRemoteChange> | — | Cells changed by someone else, keyed by gridCellKey(rowId, key): a brief ring pulse
(motion-flash, gone under reduced motion) and the author in the cell's corner for 1.5s;
only the focused cell is announced. Keep the map stable between changes. |
rowHeight | DataGridRowHeight | — | Fixed row height: short 32px, medium 40px, tall 80px (text wraps to
three lines). Cells keep to one line otherwise. Omit for content-sized rows. |
sections | DataGridSection[] | — | Host-provided groups (server-side grouping, each group paging its own
keyset), flattened into the one row list. Overrides a group column. |
selectable | boolean | false | Render the leading selection column. |
selectedIds | Set<string> | — | Controlled selection. |
selectionActions | ((selectedIds: ReadonlySet<string>, clear: () => void) => React.ReactNode) | — | The actions for the selected rows (selectable): an ActionBar docks at the bottom of the
screen, centred over the grid, while anything is selected — the count, these actions
(compose ActionBarButton), and Clear. clear empties the selection. The same bar as
DataList's. |
selectionBarLabel | string | "Selection actions" | The selection bar's accessible name. |
selectionLabel | ((count: number) => string) | (count) => `${count} selected` | The selection bar's status text. |
selectionMode | "cells" | "rows" | "rows" | rows selects whole rows (the checkbox column). cells adds spreadsheet ranges:
Shift+arrows, Shift+click and drag extend, <kbd>Shift</kbd>+<kbd>Space</kbd> a row,
<kbd>Ctrl</kbd>+<kbd>Space</kbd> a column, <kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>A</kbd> all;
copy, cut and paste (TSV and HTML), Delete clears, <kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>D</kbd>
fills down, and undo/redo keys are reported to the host. |
selectionMoreLabel | string | "More actions" | The selection bar's ⋯ trigger name. |
selectionSecondaryActions | readonly ActionBarSecondaryAction[] | — | Secondary selection actions — buttons on a wide screen, a ⋯ menu on a phone. |
sort | DataGridSort[] | — | Controlled multi-sort (priority order). Omit for uncontrolled. Passed WITHOUT
onSortChange, the sort is owned elsewhere (a toolbar's Sort control):
headers are not sort buttons, and each sorted column shows a read-only ↑/↓
(with its ordinal in a multi-sort) and aria-sort. |
sortMode | "client" | "manual" | "client" | client sorts data with the row-model engine. manual renders data
in the order given — the host sorts on the server — and the headers only
signal intent through onSortChange. |
summaryRow | DataGridSummaryRow | — | The summary row: a sticky footer of per-column functions (summaryOptions)
with the host's computed values. The grid computes nothing over its rows. As cards
(phoneLayout="cards" on a phone-width grid) it is one read-only line above them: the
label, then "Column · Function result" for each summarised column, wrapping as it must. |
toolbar | React.ReactNode | — | Host slot above the table. |
untitledLabel | string | "Untitled" | What an empty primary value reads as — in the primary cell and a card's title — muted, so a
row with no name is never a blank row. The same word Board cards use. "Empty" is whatever
renders nothing: a column render must return null (not an empty <span>) for an empty
value, and the same rule leaves an empty field out of a phone card. |
virtualize | boolean | false | Window the rows with TanStack Virtual (needs a fixed-height viewport via
maxHeight). Grouped rows window too. Off in pagedMode. |
virtualizeColumns | boolean | false | Window the columns too (fixed layout): only the columns in view (plus two either side) render, with spacer columns standing in for the rest. Needed past a few dozen columns. |
Data attributes and CSS variables on DataGrid
| Attribute | Values |
|---|---|
data-embedded | "" |
data-grid-id | mirrors a prop or state value |
data-grid-key | mirrors a prop or state value |
data-grid-row | mirrors a prop or state value |
data-layout | "auto" | "fixed" |
data-layout-mode | "cards" | "grid" |
data-prose | "off" |
data-read-only | "" |
data-slot | "data-grid" | "data-grid-filler" | "data-grid-filler-col" | "data-grid-footer" | "data-grid-head-trailing" | "data-grid-hidden-hint" | "data-grid-more" | "data-grid-more-x" | "data-grid-sort-hint" | "data-grid-summary" | "data-grid-toolbar" | "data-grid-viewport-frame" | "table" | "table-header" |
Block footer
footer as an object, for an embedded grid.
| Prop | Type | Default | Description |
|---|---|---|---|
countLabel | ((shown: number, total: number) => string) | (shown, total) => `${shown} of ${total}` | The count text, from shown and total. |
newRowLabel | string | "New row" | The new-row button's label. |
onNewRow | (() => void) | — | Add a row — shows "+ New row" at the footer's start (not while readOnly). |
shown | number | — | How many rows the block shows. |
total | number | — | How many rows there are in all. When it is more than shown, the footer's end says so. |
Column
mono is for codes and IDs — a reference, a key — and styles body cells only; the header stays in
the sans face. A number (a count, a date, an amount) is numeric: its cells and its header align
to the end unless the column sets its own align, and its digits line up in tabular-nums in the
regular font. icon puts the field's type glyph before the header label.
| Prop | Type | Default | Description |
|---|---|---|---|
header* | React.ReactNode | — | Header label. A string header also names the column for assistive technology. |
key* | string | — | Stable identifier — the React key, the sort key, the visibility key. |
accessor | ((row: T) => unknown) | — | The column's typed value for the row — what client sorting compares and
client grouping buckets. Defaults to reading row[key]. |
align | "center" | "end" | "start" | "start" | Horizontal alignment of the header and cells. |
editable | DataGridEditor<T> | — | Open this editor on Enter/F2 (or by typing, for text and number) while
the cell has grid focus. Uses EditableCell in focusMode="managed" —
the grid owns reachability, the cell owns the editor and its async status. A custom
editor's render also receives the row. |
format | ((row: T) => string) | the value as a string | Cells mode: the text a copy writes for this column's value. |
frozen | boolean | false | Stick the column to the inline start while the grid scrolls sideways. Only a LEADING run of frozen columns freezes — a frozen column after an unfrozen one scrolls. Switches the grid to the fixed layout. |
group | boolean | false | Group rows into collapsible sections by this column's value (client
grouping). One grouping column at most; ignored when the grid has
sections. |
headerAlign | "center" | "end" | "start" | undefined — the column's `align` | Where the header's content sits, apart from the values: a numeric column's figures and footer stay end-aligned while its header starts at the start, so every header leads the same way — icon, name, sort arrow, menu chevron, in that order from the start. |
headerMenu | React.ReactNode | — | Items for the header's menu (DropdownMenuItems) — opened from the
header's chevron, <kbd>Enter</kbd> or <kbd>Shift</kbd>+<kbd>F10</kbd> on the
focused header. A header with a menu sorts from the menu, not on Enter. |
icon | React.ReactNode | — | An icon before the header label — the field's type glyph (a lucide icon). Decorative: the label names the column. |
minWidth | number | 120 (auto) · 64 (fixed) | AUTO layout: the pixels this column needs before the responsive revelation
shows it (columns that no longer fit hide right-to-left; mobile
overrides). FIXED layout: the narrowest a resize can make the column. |
mobile | DataTableColumnMobile | "merge" | Responsive posture when the column no longer fits: visible never hides;
merge stacks the value into the primary (first) column's cell; hidden
drops it, which is counted and reported so the loss is never silent.
merge is the default because narrowing a viewport must never lose data
silently. |
mono | boolean | false | Render this column's BODY cells in the mono face (font-mono text-sm + tabular-nums) — for
codes and identifiers (SKUs, ticket ids, hashes). Numbers are not codes: a count, an amount
or a date column uses align: "end" and the regular face with tabular-nums. The header
cell stays in the sans face either way. |
nowrap | boolean | true, except for the primary column, which wraps unless it is `align="end"` or `mono` | Keep this column's cells on one line instead of wrapping. The primary
(first) column is the FILL column: it takes the width the others leave and
its text wraps (anywhere, so one unbroken value never widens the table).
Every other column is a FIT column: sized to its content and kept on one
line, so a short value or label never breaks. false on a fit column lets
it wrap between words instead (never inside one); true on the primary
column keeps a code or figure on one line until the table is squeezed. |
numeric | boolean | false | Set this column's BODY values in tabular figures, in the regular face — for a count, an amount,
a size or a date, so their digits line up down the column (mono already does for codes).
On a DataList's line meta line a numeric value is a figure: it keeps its whole width and the
text values beside it truncate first. |
parse | ((cell: GridClipboardCell, row: T) => unknown) | — | Cells mode: read a pasted cell into this column's value (CellChange.value). Without it a
pasted cell's text is the value. A column with neither parse nor editable is read-only
to paste, clear and fill. |
render | ((row: T, context: DataGridCellContext) => React.ReactNode) | — | Cell content. Rendered as a component ELEMENT (hooks are safe here —
unlike DataList.render, which is invoked as a plain function).
On an editable column it is the cell's display while not editing a non-empty value (an
empty one shows the editor's placeholder; a pending commit shows its value as text until the
new row arrives); without it the cell shows the editor's own display.
Cells are memoised on the row object and the column definition, so keep
columns stable (useMemo) and give a changed row a new object. |
resizable | boolean | true when the grid has `onColumnResize` | Show a resize handle on the header's trailing edge. Only meaningful with
the grid's onColumnResize. |
sortable | boolean | false | Header click sorts by this column; shift-click adds it as a secondary key. |
summaryOptions | readonly GridSummaryOption[] | — | The summary functions this column's footer cell offers (summaryRow). |
width | number | 240 for the primary (first) column, 160 for the others | Width in pixels. Declaring it switches the grid to the fixed layout. |
Accessibility
- Real
role="grid"semantics on a native table:columnheader/gridcell, the primary column's cells asrowheader,aria-colcountandaria-rowcountwith TRUE totals (ariaRowCount/ariaColCountwhen only some rows or columns are loaded; −1 while more rows exist and no total is given),aria-rowindex/aria-colindexon every row and cell, andaria-readonlyon non-editable cells. - One roving tab stop for the whole grid, kept by row id and column key (never by index), so it survives a refetch that moves its row. It roves over the header row, the group rows, the body and the summary row; the sort buttons, menu chevrons, resize handles and checkboxes inside are not stops of their own, and neither is anything a body cell renders (a link chip, a person chip, a checkbox): Alt+Enter on any cell (and Enter on a read-only one) moves into its first one, ←/ → step through them and Escape returns to the cell, so Tab leaves the grid from any cell. Arrows move the active cell (RTL-aware); a windowed-out target scrolls in and takes focus as it mounts. Tab back into the grid returns to the cell that was active, scrolled back in when its row or column was windowed out meanwhile.
- The grid is its own stacking context: its sticky header, summary row and
frozen columns never paint over the page's floating parts (an
ActionBar). - Enter/F2 opens the cell editor and suspends grid navigation; Escape closes it and restores focus to the cell; edit-mode entry announces politely.
- Multi-key sort headers carry
aria-sortand a visible ordinal per key; shift-click adds a secondary key. Each sort button is named Sort by <column>. Sorted columns always show ↑/↓; an unsorted sortable column's ⇅ stays transparent until the header is hovered (fine pointers only) or its button has keyboard focus, so touch shows only the active arrows and unsortable columns show nothing — the same header parts as DataList. - Responsive revelation never hides data silently:
merge(the default) stacks the value into the primary cell with its header as a visually hidden prefix (Stage: Open), and the only posture that drops a column (mobile: "hidden") reports the count in the toolbar. The grid'saria-describedbypoints at that count, and at the "Sorted by …" line when a sorted column has left the header row. - The scroll viewport is keyboard-reachable exactly when it scrolls, and the roving cell's focus outline is pulled inside so the viewport cannot clip it.
| Key | Action |
|---|---|
| ↑↓←→ | Move the active cell; ↑ from the first row reaches the header row. |
| Home / End | First / last cell in the row. |
| ⌘/Ctrl+Home/End | First / last cell of the grid. |
| ⌘/Ctrl+arrow | The data edge in that direction. |
| PageUp / PageDown | One screen of rows. |
| Enter / F2 | Edit the focused cell, keeping the value (suspends grid nav). |
Space on a cell · Enter on the primary cell (with onRowOpen) | Open the row's record; F2 still edits the primary cell. |
| A character | Edit, replacing the value (every editor that takes text). |
| Alt+Enter (or Enter on a read-only cell) with links or chips | Move into its first one; ←/→ step, Escape returns. |
| Enter / Tab / Shift+Tab while editing | Commit and move down / right / left. |
| Escape | Close the editor, restore grid focus. |
| Enter on a header | Open its menu (headerMenu, which holds the sort), else sort (shift adds a key). |
| Shift+F10 / the menu key | The focused cell's menu (cellMenu) or the header's menu. |
| Alt+←/→ on a header | Resize by 8px (Shift: 32px). |
| Space on a checkbox or group row | Toggle it. |
| Shift+arrows, Shift+click (cells mode) | Extend the range. |
| Shift+Space · Ctrl+Space · ⌘/Ctrl+A (cells mode) | Select the row · the column · everything. |
| ⌘/Ctrl+C, X, V (cells mode) | Copy, cut, paste. |
| Delete / Backspace (cells mode) | Clear the range. |
| ⌘/Ctrl+D (cells mode) | Fill down. |
| ⌘/Ctrl+Z, ⇧⌘Z / Ctrl+Y (cells mode) | Undo, redo (the host's). |
| ↓ on the last row | Load the next page (when loadMore.hasMore). |
| Tab (not editing) | Leave the grid — it is one tab stop. |
The states the verification lanes exercise for this item (generated from component-contracts.json):
| Contract | States tested |
|---|---|
| Behaviour | idle, sorted, multi-sorted, manual-sorted, selecting, grouped, group-collapsed, group-loading-more, editing, committing, loading, loading-more, empty, virtualized, columns-virtualized, frozen, resizing, summarized, paged, menu-open, range-selected, copying, pasting, filling, remote-change |
| Accessibility | keyboard, labeled, status-announcement, aria-grid |
| Visual | default, selected-row, sorted-header, editing-cell, loading, empty, frozen-edge, resizing, summary |
Column resize handle
ColumnResizeHandle (in data-table-parts) is the header's trailing-edge grip: a separator with
aria-orientation="vertical" and its aria-valuenow/min/max, named Resize Qty. Standalone it
is a tab stop with ←/→ (8px, Shift 32px) and
Home/End; inside the grid the same steps run from the focused header with Alt. Its hit area is 24px wide.
Summary row
GridSummaryCell (in data-table-parts) is one footer cell: a quiet Select of the column's
summaryOptions (with a built-in None) named Sum of Qty, and the host's result beside it. An
unsummarised cell shows Summarize only on hover or focus. The summary row is sticky at the bottom
of the viewport and counts as a row in aria-rowcount. The primary column's cell shows
summaryRow.label ("412 rows") before its own picker. The cells rove like any row; Enter
or Space opens the picker, and closing it returns focus to the cell.
Trailing header control
headerTrailing is a render function: spread the focus props it is handed onto its one control (an
"add field" button, or a PopoverTrigger's render button), and the control joins the header row's
roving focus — → from the last header reaches it — instead of becoming a second tab stop.
Do / Don't
View Tabs
A table's saved views as a tab row — shared views, then the reader's own, a lock on locked views, a create menu, and one Select on narrow screens.
Block Header
A live block's header row in a page — icon · title · View ▾ · Filter · More · ↗ Open — with an empty state that hosts the source picker.