Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.4.0
Accessibility pattern
APG grid

Last updated

Globex expansionProposalMel$48,000
Umbrella upsellWonPriya$22,000
Northwind seatsProposalAda$15,600
Acme renewalQualifiedPriya$12,400
Initech pilotQualifiedAda$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-grid

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/data-grid

Run 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:

  1. Cells render as component elements, so a render implementation may use hooks (DataList invokes render as a plain function and cannot allow that).
  2. The grid sorts its own data — that is what the row-model engine is for. sort/onSortChange stay controllable for URL state. Pass sort without onSortChange when 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) and aria-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.

BehaviourWhere it lives
Data fetching, filters, saved viewsHost — FilterBuilder + the toolbar slot
The cell write + conflict handlingHost — onCellCommit is a request; cellStatus is your word
Pagination UIThe footer slot; loadMore covers the continuous case
Simple presentational tablesDataList — 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

LayoutTurns on whenColumns
Auto (default)—Fill and fit, like DataList; responsive revelation merges or drops columns on narrow screens.
FixedAny column has width, frozen or resizable, or the grid has onColumnResize or virtualizeColumnstable-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.

Sample requests
Name
Status
Qty
Owner
Site
Sample request 1
2PriyaPune
Sample request 2
4MelLeeds
Sample request 3
6AdaAustin
Sample request 4
8PriyaPune
Sample request 5
10MelLeeds
5 of 117

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
Nothing here

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 renewalQualified$12,400
Initech pilotQualified$9,800
Globex expansionProposal$48,000
Northwind seatsProposal$15,600
Umbrella upsellWon$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 1Qualified$1,000
Deal 2Proposal$2,000
Deal 3Won$3,000
Deal 4Qualified$4,000
Deal 5Proposal$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, cut and paste events (never the async Clipboard API) as TSV plus a bare HTML table, read by grid-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's parse(cell, row) turns a pasted cell into its value and format(row) writes its copy text. A column with neither parse nor editable is read-only to paste, clear and fill. Rows that run past the last loaded row go to onPasteOverflow — 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 by gridCellKey(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's announcement is spoken.
Name
Status
Qty
Sample request 12
Sample request 24
Sample request 36
Sample request 48
Sample request 510
Sample request 612
Sample request 714
Sample request 816

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 renewalQualified
Globex expansionProposal
Initech pilotQualified

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:

mobileWhen 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 renewalQualified$12,400Priya
Globex expansionProposal$48,000Mel
Initech pilotQualified$9,800Ada

API Reference

PropTypeDefaultDescription
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.
actionsRefReact.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.
activeRowIdstring | 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-labelstring"Data grid"Accessible name for the grid.
ariaColCountnumberthe visible column count (plus the selection column)The true column count when the host hides some columns from the grid altogether (aria-colcount).
ariaRowCountnumber—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).
ariaRowIndexOffsetnumber0Rows 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").
cardFieldsnumber3phoneLayout="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.
classNamestring—Extra classes for the root.
clearSelectionLabelstring"Clear selection"The selection bar's Clear button, as a screen reader hears it.
columnDividersbooleanfalseA 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.
columnOrderstring[]—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.
columnPickerbooleantrue, 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.
columnVisibilityRecord<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.
editingTintbooleanfalseFill the cell being edited with the accent tint, so an editor floating over it (or an inline one) reads as the cell's own.
embeddedbooleanfalseA 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).
emptyStateReact.ReactNode—Content when data is empty and not loading.
endSpacernumber | booleanfalseAn 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).
fillbooleanfalseFill 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.
footerDataGridBlockFooter | 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.
groupStateGroupState—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.
loadingbooleanfalseShow skeleton rows instead of data.
loadingRowsnumber5How 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.
loadMoreDataGridLoadMoreProps—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).
maxHeightstring—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.
maxRowsnumber25 when `embedded`, otherwise no capThe 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.
maxSortKeysnumber2Maximum 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.
pagedModeCursorPagerProps—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.
readOnlybooleanfalseNothing 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.
refReact.Ref<HTMLDivElement>—Ref forwarded to the root (data-slot="data-grid").
remoteChangesReadonlyMap<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.
rowHeightDataGridRowHeight—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.
sectionsDataGridSection[]—Host-provided groups (server-side grouping, each group paging its own keyset), flattened into the one row list. Overrides a group column.
selectablebooleanfalseRender the leading selection column.
selectedIdsSet<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.
selectionBarLabelstring"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.
selectionMoreLabelstring"More actions"The selection bar's ⋯ trigger name.
selectionSecondaryActionsreadonly ActionBarSecondaryAction[]—Secondary selection actions — buttons on a wide screen, a ⋯ menu on a phone.
sortDataGridSort[]—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.
summaryRowDataGridSummaryRow—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.
toolbarReact.ReactNode—Host slot above the table.
untitledLabelstring"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.
virtualizebooleanfalseWindow the rows with TanStack Virtual (needs a fixed-height viewport via maxHeight). Grouped rows window too. Off in pagedMode.
virtualizeColumnsbooleanfalseWindow 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

AttributeValues
data-embedded""
data-grid-idmirrors a prop or state value
data-grid-keymirrors a prop or state value
data-grid-rowmirrors 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"

footer as an object, for an embedded grid.

PropTypeDefaultDescription
countLabel((shown: number, total: number) => string)(shown, total) => `${shown} of ${total}`The count text, from shown and total.
newRowLabelstring"New row"The new-row button's label.
onNewRow(() => void)—Add a row — shows "+ New row" at the footer's start (not while readOnly).
shownnumber—How many rows the block shows.
totalnumber—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.

PropTypeDefaultDescription
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.
editableDataGridEditor<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 stringCells mode: the text a copy writes for this column's value.
frozenbooleanfalseStick 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.
groupbooleanfalseGroup 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.
headerMenuReact.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.
iconReact.ReactNode—An icon before the header label — the field's type glyph (a lucide icon). Decorative: the label names the column.
minWidthnumber120 (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.
mobileDataTableColumnMobile"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.
monobooleanfalseRender 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.
nowrapbooleantrue, 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.
numericbooleanfalseSet 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.
resizablebooleantrue when the grid has `onColumnResize`Show a resize handle on the header's trailing edge. Only meaningful with the grid's onColumnResize.
sortablebooleanfalseHeader click sorts by this column; shift-click adds it as a secondary key.
summaryOptionsreadonly GridSummaryOption[]—The summary functions this column's footer cell offers (summaryRow).
widthnumber240 for the primary (first) column, 160 for the othersWidth 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 as rowheader, aria-colcount and aria-rowcount with TRUE totals (ariaRowCount/ariaColCount when only some rows or columns are loaded; −1 while more rows exist and no total is given), aria-rowindex/aria-colindex on every row and cell, and aria-readonly on 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-sort and 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's aria-describedby points 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.
KeyAction
↑↓←→Move the active cell; ↑ from the first row reaches the header row.
Home / EndFirst / last cell in the row.
⌘/Ctrl+Home/EndFirst / last cell of the grid.
⌘/Ctrl+arrowThe data edge in that direction.
PageUp / PageDownOne screen of rows.
Enter / F2Edit 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 characterEdit, replacing the value (every editor that takes text).
Alt+Enter (or Enter on a read-only cell) with links or chipsMove into its first one; ←/→ step, Escape returns.
Enter / Tab / Shift+Tab while editingCommit and move down / right / left.
EscapeClose the editor, restore grid focus.
Enter on a headerOpen its menu (headerMenu, which holds the sort), else sort (shift adds a key).
Shift+F10 / the menu keyThe focused cell's menu (cellMenu) or the header's menu.
Alt+←/→ on a headerResize by 8px (Shift: 32px).
Space on a checkbox or group rowToggle 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 rowLoad 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):

ContractStates tested
Behaviouridle, 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
Accessibilitykeyboard, labeled, status-announcement, aria-grid
Visualdefault, 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

Do
Reach for DataList first — DataGrid earns its weight only when you need grouping, inline editing, multi-key sort, frozen or resizable columns, or virtualization.
Don't
Rebuild columns or cellStatus on every render of a large grid — cells are memoised on the row object and the column definition, so a fresh array re-renders every cell.

On this page