Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Data Grid

The full-parity grid — multi-key sort, column picker, grouping, load-more, virtualization, and APG cell navigation with inline 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

Add Data Grid from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

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

The same command installs the registry items it composes: @vegastack/auto-save-input, @vegastack/button, @vegastack/data-table-parts, @vegastack/dropdown-menu, @vegastack/editable-cell, @vegastack/table, @vegastack/use-announcer.

It also adds the sanctioned engines to your package.json: @tanstack/react-table (sorted row-model engine), @tanstack/react-virtual (row windowing/measurement engine).

Usage

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" },
  ]}
  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); windowing from TanStack Virtual behind the virtualize flag; the APG grid keyboard layer — which no library ships — is the component's own.

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.

Scope

BehaviourWhere it lives
Data fetching, filters, saved viewsHost (G7) — 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 chromedata-table-parts — sort header, selection, async states

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

Grouping

One group column renders a collapsible <tbody> section per value — valid HTML where a div-based collapsible between tbody and tr is not. Grouping and virtualize are mutually exclusive.

Stage
Acme renewalQualified$12,400
Initech pilotQualified$9,800
Globex expansionProposal$48,000
Northwind seatsProposal$15,600
Umbrella upsellWon$22,000

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; Escape restores grid navigation; a rejected commit reverts and announces.

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

Keyboard-continuous load-more

past the last row fetches the next page — paging never breaks the keyboard flow.

NameStage
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. design.md § DataGrid: data is never silently lost, 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.

DealStageAmountOwner
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).
data*T[]Row data. The grid sorts it itself (the row-model engine).
getRowId*(row: T) => stringStable, unique row id — selection and edit identity.
aria-labelstring"Data grid"Accessible name for the grid.
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.
classNamestringExtra classes for the root.
columnOrderstring[]Column order (array of keys), applied left to right. Controlled-only: the grid ships no reorder affordance — the host owns it (a settings surface). Omit to use the declared column order.
columnPickerbooleantrueRender 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.
emptyStateReact.ReactNodeContent when data is empty and not loading.
footerReact.ReactNodeHost slot below the table.
groupStateRecord<string, "collapsed" | "expanded">Controlled collapsed state per group value. Omit for uncontrolled.
loadingbooleanfalseShow skeleton rows instead of data.
loadMoreDataGridLoadMoreKeyboard-continuous load-more at the last row.
maxHeightstringScroll-viewport max height as a CSS length — required for virtualize, useful alone for sticky headers. Flows to the Table container through --data-grid-max-height.
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).
onColumnVisibilityChange((visibility: Record<string, boolean>) => void)Fired when the picker (or host) changes visibility.
onGroupStateChange((state: Record<string, "expanded" | "collapsed">) => void)Fired when a group toggles.
onSelectionChange((selectedIds: Set<string>) => void)Selection change.
onSortChange((sort: DataGridSort[]) => void)Fired with the next sort array on header activation.
refReact.Ref<HTMLDivElement>Ref forwarded to the root (data-slot="data-grid").
selectablebooleanfalseRender the leading selection column.
selectedIdsSet<string>Controlled selection.
sortDataGridSort[]Controlled multi-sort (priority order). Omit for uncontrolled.
toolbarReact.ReactNodeHost slot above the table.
virtualizebooleanfalseWindow the rows with TanStack Virtual (needs a fixed-height viewport via maxHeight). Ignored while a group column exists.

Data attributes and CSS variables on DataGrid

AttributeValues
data-collapsed""
data-grid-idmirrors a prop or state value
data-groupmirrors a prop or state value
data-selected""
data-slot"data-grid" | "data-grid-cell" | "data-grid-footer" | "data-grid-group-row" | "data-grid-head" | "data-grid-hidden-hint" | "data-grid-load-more" | "data-grid-merged" | "data-grid-row" | "data-grid-section" | "data-grid-toolbar" | "data-grid-virtual-pad"
--data-grid-virtual-padCSS custom property

Column

PropTypeDefaultDescription
header*React.ReactNodeHeader label.
key*stringStable identifier — the React key, the sort key, the visibility key.
accessor((row: T) => unknown)Sort/group value for the row. Defaults to reading row[key].
align"center" | "end" | "start""start"Horizontal alignment of the header and cells.
editableEditableCellEditorOpen this editor on Enter/F2 while the cell has grid focus. Uses EditableCell in focusMode="managed" — the grid owns reachability, the cell owns the editor and its async status.
groupbooleanfalseGroup rows into collapsible sections by this column's value. One grouping column at most; grouping disables virtualize.
minWidthnumber120Pixels this column needs before the responsive revelation shows it. Columns that no longer fit hide right-to-left; mobile overrides.
mobile"hidden" | "merge" | "visible""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 in the toolbar so the loss is never silent. merge is the default because design.md § DataGrid requires that data is never silently lost.
monobooleanfalseRender this column's values in the mono numeral face (text-code + tabular-nums), so figures line up down the column.
nowrapbooleantrue for `align="end"` and `mono` columns, false otherwiseKeep this column's cells on one line instead of wrapping. Cells wrap by default (D18) — scrolling is reserved for tables that are genuinely wide, not forced by one long value. Figures and mono values are the exception and opt IN automatically.
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). Ignored when the column is editable — the editor owns that cell.
sortablebooleanfalseHeader click sorts by this column; shift-click adds it as a secondary key.

Accessibility

  • Real role="grid" semantics on a native table: columnheader/gridcell with aria-colcount, aria-rowcount (−1 while more rows exist), aria-rowindex/aria-colindex, and aria-readonly on non-editable cells.
  • One roving tab stop for the whole grid: arrows move the active cell (RTL-aware), Home/End jump within the row, Ctrl+Home/End to the grid's corners.
  • 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.
  • Responsive revelation never hides data silently: merge (the default) stacks the value into the primary cell, and the only posture that drops a column (mobile: "hidden") reports the count in the toolbar.
  • 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.
Home / EndFirst / last cell in the row.
Ctrl+Home/EndFirst / last cell of the grid.
Enter / F2Edit the focused cell (suspends grid nav).
EscapeClose the editor, restore grid focus.
on the last rowLoad more (when loadMore.hasMore).

The states the verification lanes exercise for this item (generated from component-contracts.json):

ContractStates tested
Behaviouridle, sorted, multi-sorted, selecting, grouped, group-collapsed, editing, committing, loading, loading-more, empty, virtualized
Accessibilitykeyboard, labeled, status-announcement, aria-grid
Visualdefault, selected-row, sorted-header, editing-cell, loading, empty

Do / Don't

Do
Reach for DataList first — DataGrid earns its weight only when you need grouping, inline editing, multi-key sort, or virtualization.
Don't
Wire cell-level overflow-hidden for truncation — use min-w-0 + an inner truncate span; cell clipping breaks the checkbox hit-area invariant.

On this page