Skip to main content

DataGrid

Data

The spreadsheet-grade preset of DataTable (interaction="grid"): one tab stop, arrow keys across header and body cells, Excel-style cell ranges (drag, Shift, Ctrl/⌘), Ctrl/⌘+A, and Ctrl/⌘+C copies what the cells display as tab-separated text.

import { DataGrid } from "@elabs-ai/components-data";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-data

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui DataGrid component (from @elabs-ai/components-data) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look DataGrid up before writing code: `npx -y @elabs-ai/components-cli docs DataGrid` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-data is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-data` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-data`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Examples

15 live examples

Virtualized

20,000 rows, virtualized: arrow keys and Page Up / Down scroll the focused cell into view.

With Row Selection

Row selection in a grid: Space toggles the focused row; checkboxes stay clickable.

Pinned Columns

Pinned columns keep their place in the navigation order (start, centre, end).

Filtering

Every header has a filter button: text conditions (Trade), a value checklist with counts (Desk, Side, Trader), number conditions (Quantity … P&L), dates with relative ranges (Trade date) and yes / no (Settled). The floating row under the headers filters as you type — try `>5000` in Quantity or `-1000..0` in P&L. Active filters show as chips above the grid.

Filtering Large

Filtering 20,000 virtualized rows: the value checklist counts every row, not just the rendered ones.

Editing

Editing: Enter, F2, a double-click or just typing edits a cell (Desk and Side are lists, Trade date a date, Settled toggles with Space). Enter / Tab commit and move, Escape cancels. Paste a block from a spreadsheet, Delete clears, Ctrl/⌘+D fills down, Ctrl/⌘+Z / Ctrl/⌘+Y undo and redo. Notional is derived and read-only; Quantity rejects anything but a whole number above 0.

Grouping

Row grouping: "Group by this column" in any column menu (Desk and Trader are grouped here). Group rows show the value, how many rows they hold and each column's `meta.aggregate` (sums, the mean price, the latest date); Enter or the chevron opens a group. The totals row sums every row that passes the filters.

Tree Data

Tree data: `getSubRows` nests rows; parents expand in place and children indent under them.

Master Detail

Master / detail: each row expands into whatever `renderDetail` returns.

Pivot

Pivot: `pivotData` turns trades into desk × instrument notional sums, with row and column totals.

Chart Range

Chart a range: select cells, right-click, "Chart selection". The grid hands the block to `onChartRange` (shown here as JSON) — pass it to a chart such as charts' AutoChart; the grid itself never draws one.

Live Updates

Live data: a dozen trades re-price every 0.7 s. `flashChanges` flashes the cells that moved — green up, red down — comparing only the rows whose object changed, so it stays cheap at any row count.

Infinite Loading

Infinite loading: the next 50 rows are fetched as the end comes into view.

Wide Columns

200 columns × 5,000 rows with row AND column virtualization: only the cells in view render (the Row column stays pinned). Arrow keys, Ctrl/⌘+End and find scroll hidden columns into view.

API

DataGrid

PropTypeDescription
columnsrequiredColumnDef<TData, TValue>[]
datarequiredTData[]
toolbar(table: TanstackTable<TData>) => ReactNodeRender a toolbar above the table; receives the table instance.
enablePaginationbooleanEnable client-side pagination.
pageSizenumber
hidePaginationWhenSinglebooleanHide the pager once there's genuinely only one page (`table.getPageCount() <= 1`). Default `true`. When `manualPagination` is set without `rowCount`/`pageCount`, the page count isn't knowable (TanStack falls back to the current page's row count) — in that ambiguous case the pager still renders regardless of this flag, so the existing dev warning (#227) stays the diagnostic instead of a silently-hidden pager. Set to `false` to always show the pager (e.g. while a server total is still loading and you'd rather show a disabled pager than none).
globalFilterstringControlled global filter value. When provided, the table reflects this value and the component manages no internal filter state. Keep the source of truth in the app and pass it down — never mutate the filter during render (e.g. `table.setGlobalFilter()` in `toolbar`), which loops.
onGlobalFilterChange(value: string) => voidFires when the table requests a global-filter change (e.g. from typeahead).
sortingSortingStateControlled sorting state. When provided the component is sorted-controlled.
onSortingChangeOnChangeFn<SortingState>
columnVisibilityVisibilityStateControlled column-visibility state.
onColumnVisibilityChangeOnChangeFn<VisibilityState>
columnFiltersColumnFiltersStateControlled column-filters state.
onColumnFiltersChangeOnChangeFn<ColumnFiltersState>
paginationPaginationStateControlled pagination state.
onPaginationChangeOnChangeFn<PaginationState>
columnPinningColumnPinningStateControlled column-pinning state (#333) — the columns frozen against the left and/or right edge while the rest of the table scrolls horizontally. When provided the component is pinning-controlled; otherwise it manages the slice internally and can be seeded once via `initialView.columnPinning`. A pinned column MUST declare an explicit `size` in its `ColumnDef`: the sticky offset is computed from TanStack's `column.getStart("left")` / `getAfter("right")`, which sum the DECLARED sizes, so an auto-width column would render at a width that doesn't match its own offset. A dev-only warning fires for a pinned column with no `size`. Pinning is a LAYOUT concern, not a query concern — it is client-only and never joins `DataTableServerArgs` / `onServerChange`.
onColumnPinningChangeOnChangeFn<ColumnPinningState>
enableColumnResizingbooleanOpt in to column resizing (#12): a drag handle renders on every resizable column's trailing edge — pointer-draggable (TanStack's own `header.getResizeHandler()`) and keyboard-operable (ArrowLeft/ArrowRight on the focused handle, per the WAI-ARIA separator-as-slider practice). Default `false` so a table that doesn't opt in renders byte-identical markup to before this feature existed — no handle, no per-cell width styling.
columnResizeMode"onChange" | "onEnd"When `columnSizing` updates: `"onChange"` (default here — TanStack's own default is `"onEnd"`) live-updates while dragging; `"onEnd"` updates once on release. Only meaningful when `enableColumnResizing` is set.
columnSizingColumnSizingStateControlled column-widths state (#12), keyed by column id — the SAME controlled/uncontrolled shape as `columnPinning`/`rowSelection`. Uncontrolled sizing can be seeded once via `initialView.columnSizing`. A pinned column's sticky offset (`getStart("left")`/`getAfter("right")`) already sums `column.getSize()`, which folds in a `columnSizing` override automatically — so pinning and resizing compose with no extra wiring once this state reaches the table. Sizing is a LAYOUT concern, like `columnPinning`/`rowSelection` — it is client-only and never joins `DataTableServerArgs` / `onServerChange`.
onColumnSizingChangeOnChangeFn<ColumnSizingState>
rowSelectionRowSelectionStateControlled row-selection state (#11) — which rows are checked, keyed by row id (see `getRowId`). When provided the component is selection-controlled; otherwise it manages the slice internally and can be seeded once via `initialView.rowSelection`. Pair it with a selection column built by `createSelectionColumn` (or drive it yourself off the `table` instance handed to `toolbar`). Selection is a LAYOUT/UI concern, not a query concern — like `columnPinning`, it is client-only and never joins `DataTableServerArgs` / `onServerChange`.
onRowSelectionChangeOnChangeFn<RowSelectionState>
enableRowSelectionboolean | ((row: Row<TData>) => boolean)Which rows can be selected: `true`/`false` for all rows, or a predicate evaluated per row. Passed straight through to `useReactTable`. Default (TanStack's own): `true`.
enableMultiRowSelectionbooleanAllow more than one row to be selected at once. Default (TanStack's own): `true`. Set `false` for single-select (radio-style) behaviour.
getRowId(row: TData, index: number) => stringStable row id, independent of row INDEX. TanStack's default id is set ONCE per row object when the core row model is built, then reused by reference through sorting/filtering — so a client-side sort or filter does NOT disturb selection identity even without this prop. The real hazard is a `data` array replacement: when the app passes NEW object references (a re-fetch, an optimistic update), TanStack rebuilds the core row model from scratch and reassigns default (index-based) ids, so a row that kept its position but got a new object still keeps its selection — but one that MOVED position silently inherits whatever selection belonged to the id now sitting at its old index. This is unavoidable under `manualPagination`: each page IS a fresh `data` array, so the default index-based id restarts at `0` on every page and a selection made on one page can collide with a different record on the next. Supply `getRowId` whenever `data` can be replaced with new object references (including every server-paginated table) so identity survives the replacement instead of falling back to index.
initialViewPartial<DataTableViewState>One-shot rehydrate for uncontrolled slices only (ignored for any slice whose corresponding controlled prop is set). Maps to `useReactTable`'s `initialState`.
manualSortingbooleanWhen true, sorting is handled by the server. Pass `sorting` (controlled) and handle `onServerChange` to re-fetch with the new sort params. NOTE: controlled ≠ manual — a controlled `sorting` with `manualSorting:false` still sorts locally.
manualFilteringbooleanWhen true, filtering is handled by the server. NOTE: a controlled `columnFilters` with `manualFiltering:false` still filters locally.
manualPaginationbooleanWhen true, pagination is handled by the server.
rowCountnumberTotal row count — used by the server model so TanStack can derive page count. Required when `manualPagination` is true and `pageCount` is not provided.
pageCountnumberTotal page count — alternative to `rowCount` for server pagination. When both are provided, `pageCount` wins.
onServerChange(args: DataTableServerArgs) => voidFired after any manual-slice change with the current {pagination, sorting, columnFilters, globalFilter}. The component never fetches; the app must re-fetch and update `data`.
loadingbooleanWhen true: overlay spinner; on empty+loading show skeleton rows instead of empty message.
enableRowVirtualizationbooleanOpt-in to row virtualization (for very large lists). Mutually exclusive with enablePagination in practice — if both are set, virtualization wins: every row stays reachable by scrolling and no pager renders.
estimateRowHeightnumberEstimated row height in px — only the virtualizer's FIRST guess. The table measures its first rendered row and uses that as the estimate from then on, so uniform rows never shift the scroll math. Default: 40.
rowHeightnumberFixed row height in px for a virtualized table. Rows are then never measured, which is the fastest path for very large data. Use it only when no row can grow (no wrapping text, markdown or multi-line cells): a taller row still renders fully, but the scrollbar's length drifts.
overscannumberVirtualizer overscan (rows rendered above/below the visible window). Default: 8.
maxBodyHeightstringCSS max-height of the scroll container in virtualized mode. Default: "32rem".
loadingRowsnumberNumber of skeleton placeholder rows to render while loading. Defaults to `pageSize` (non-virtualized) or `min(10, pageSize)` (virtualized).
zebrabooleanGentle alternating row stripes ("zebra") as the row-separation cue, instead of a hairline divider between every row. Default `true` — the stripe is the single separation gesture, so rows carry no divider (a divider on a striped row would be a redundant boundary). Set `false` for the classic line model (a `border-border-strong` divider between rows, no stripes).
columnDividersbooleanDraw a quiet `--rule` hairline between columns (header and body). Off by default. Pinned cells keep their own seam and never take a divider.
layout"auto" | "table" | "cards"`"table"` (default): always a `<table>`. `"cards"`: always one card per row (a `<dl>` of header → cell). `"auto"`: cards while the table's own container is narrower than 450 px, a `<table>` above. Only the markup changes; sorting, filtering, paging and selection use the same table.
stickyRowsDataTableStickyRows<TData>Rows pinned to the top or bottom of every page. An "average" or "total" row stays put through sorting, paging and search. Receives each record and its `data` index.
showRanksbooleanPrepend a 1…n rank column, in `data` order. Sticky rows are not ranked; the rank travels with its row — sorting never renumbers it.
rankLabelstringWhat the rank column is CALLED — its accessible name, its `title`, the term in the cards layout, and the key printed above the table. Defaults to the locale seam (`data.table.rankHeader` / `data.table.rankKey`). The number is the row's position in `data`, not its position on screen, so beside a sorted column it reads 2, 1, 6, 4 on purpose. A column headed by a bare "#" gives a reader no way to know that; this names it.
density"default" | "compact"`"compact"` tightens row and header height. Default `"default"`.
mergeEmptyHeadersbooleanSpan an ungrouped column's header over the empty group rows. With grouped headers, the placeholder cells above a column merge into one.
searchMode"contains" | "exact"`"exact"`: a row matches only when a cell equals the query. Default `"contains"` (TanStack's substring search); both ignore case.
hideHeaderbooleanHide the header row visually (a pixel heatmap). The headers stay for screen readers, and a focused sort button still shows itself. Sorting is then KEYBOARD-reachable, not clickable: the band has no height, so it offers no hit area — deliberately, since an invisible click target over the first data row would be worse than none. Tab to the column's sort button (it becomes visible on focus) and press Enter.
enableRowReorderbooleanOpt-in row drag-reorder. Off by default — an existing table renders byte-identical markup with no extra DOM per row until this is set. Fully controlled like every other slice: the component never mutates `data` itself, it only reports the move via `onRowReorder`; the caller re-orders `data` in response. Keyboard-operable out of the box (`@dnd-kit`'s default keyboard sensor): Space/Enter picks a row up, Arrow Up/Down moves it, Space/Enter drops it, Escape cancels. Every position change is announced through a live region (WCAG 4.1.3). Mutually exclusive with `enableRowVirtualization` — a windowed table can't keep dnd-kit's sortable list and a virtualizer in sync, so reorder is silently disabled (a dev warning fires) when both are set. Combining it with active `sorting` also fires a dev warning (both still work, but a sort re-orders the very rows a drag just moved, which reads as broken). Table-only. The card layout (`layout="cards"`, or `"auto"` at the narrow tier) has no grip column and no row to drop onto, so reorder is a no-op there and `onRowReorder` never fires; a dev warning says so once per mount.
onRowReorder(from: number, to: number, row: TData) => voidFires when a row is dropped in a new position. `from`/`to` are indices into the **`data` array you passed in** — never into the sorted, filtered or paginated view the table renders — so they are safe to use directly with `arrayMove`/`slice`+`splice`/immer against your own `data`, unchanged by an active sort or by client-side pagination (the dragged row's true index in the full array, not its index on the current page). Under `manualPagination`, `data` IS the current page, so `from`/`to` are page-relative — reorder that page's own array with them. `row` is the moved record (`data[from]`).
rowReorderHandle"cell" | "row"Where the drag activator lives. `"cell"` (default) renders a dedicated grip-handle column so the rest of the row keeps its ordinary click/ keyboard behavior untouched. `"row"` makes the whole row itself the drag activator (no extra column) — reach for this only when the row has no other primary interaction (e.g. no `onRowClick`), since a whole-row activator and a row click target the same surface.
onRowClickDataTableRowClickHandler<TData>Fires when a row is activated (#337). Setting it adds ONE activation target per row: a visually-hidden `<button>` rendered inside the row's first cell. That button is the row's keyboard tab stop and its accessible name; a pointer click anywhere else in the row resolves to the same handler, so mouse and keyboard converge on one control instead of two competing ones (a focusable `<tr>` cannot carry an activation role without destroying `row` table semantics). Guarded: a click that originates on a nested interactive control (button/link/input/checkbox/…) or is the tail end of a text-selection drag does NOT fire it. Optional; omitting it renders rows exactly as before.
rowActionLabel(row: Row<TData>) => stringAccessible name for the row's hidden activation button (#337). Only read when `onRowClick` is set. Defaults to the row's first visible cell value when that is a string/number (the row's primary identifier — the same naming a link in that cell would get), else the localized `data.table.rowAction` fallback. Supply it whenever the first cell isn't a good name for the row.
rowClassName(row: Row<TData>) => stringPer-row className, merged alongside the existing zebra/line/hover/selected classes via `cn()` (so it can't accidentally clobber them) (#337).
captionReactNodeAccessible name for the table, rendered as a visually-hidden (`sr-only`) `<caption>` — the first child of `<table>`. Screen readers announce it as the table's name and it makes column-header navigation meaningful. Optional; omit it only when the surrounding page already labels the table unambiguously (e.g. an adjacent heading) (#338).
enableColumnReorderbooleanLet people reorder columns by dragging a header (and, in grid mode, with Shift+←/→ on a focused header). Pinned columns reorder within their own pinned block. Default `false`; `DataGrid` turns it on.
autoSizeStrategy"fit" | "content" | "none"How a grid (`interaction="grid"` with resizing) sizes its columns on its own: `"fit"` scales them to fill the width — on mount and whenever the container resizes — until the user sizes a column themselves; `"content"` auto-sizes every column to its rendered content once, on mount; `"none"` keeps the declared sizes. Default `"none"`; `DataGrid` uses `"fit"`.
enableColumnMenubooleanA per-column options menu in every leaf header: sort, pin, move, auto-size, fit, hide, reset (plus `columnMenuItems`). Default `false`; `DataGrid` turns it on. In grid mode Alt+↓ on a header opens it.
columnMenuItems(column: Column<TData, unknown>) => DataTableColumnMenuItem[]Extra entries appended to a column's menu.
enableContextMenubooleanRight-click (Shift+F10) menu on body cells in grid mode: Copy, Copy with headers, Export to CSV, plus `contextMenuItems`. Default `false`; `DataGrid` turns it on.
contextMenuItems(context: { row: Row<TData>; column: Column<TData, unknown>; cellSelection: DataTableCellSelection; }) => DataTableContextMenuItem[]Extra context menu entries for the right-clicked cell.
exportFileNamestringFile name (without extension) for "Export to CSV". Default `"export"`.
showStatusBarbooleanA status bar under the table: row / filtered / selected counts and, in grid mode, Count / Sum / Average / Min / Max of the selected range. Default `false`; `DataGrid` turns it on.
enableFilterUIbooleanA filter button in every filterable leaf header, opening the column's filter panel: text / number / date conditions (two, joined by AND / OR, dates with relative ranges such as "Last 30 days"), a checklist of the column's values with counts and search (`set`), or yes / no. The kind comes from `meta.filter`, else from the data. Filters are plain JSON models in the `columnFilters` slice, so views and agents can save and set them. Default `false`; `DataGrid` turns it on.
floatingFiltersbooleanA row under the headers with a filter field per column: type to filter a text column, `>100`, `<=5`, `!=0` or `10..20` in a number column; other kinds show their summary and open the panel. Requires `enableFilterUI`.
showFilterChipsbooleanChips above the table naming every active column filter ("Region: EU, US"), each removable, plus Clear all. Default `false`; `DataGrid` turns it on.
enableFindbooleanGrid mode: Ctrl/⌘+F inside the grid opens a find bar that searches every row the grid holds (after filters, rendered or not) in the text the cells display, counts the matching cells, steps through them with Enter / Shift+Enter (moving the active cell) and highlights them. Default `false`; `DataGrid` turns it on.
onCellEdit(changes: DataTableCellChange[]) => voidGrid mode editing. Columns opt in with `meta.editable`; people edit with Enter / F2 / typing / double-click (Enter and Tab commit and move, Escape cancels), paste TSV from a spreadsheet into ranges, clear with Delete, cut with Ctrl/⌘+X, fill down with Ctrl/⌘+D and undo / redo with Ctrl/⌘+Z / Ctrl/⌘+Y. Values are parsed per `meta.editor` (inferred) and checked with `meta.validate`. The grid never owns the data: every edit, paste, undo arrives here as ONE batch of changes — apply it to your rows (`applyCellChanges` does it for key columns) and pass them back.
enableGroupingbooleanRow grouping UI: "Group by" in the column menu, a grouping bar naming the groups (each removable), expandable group rows showing the group value, its row count and every column's `meta.aggregate`. Grouping itself follows the `grouping` slice, with or without this flag.
groupingGroupingStateControlled row grouping (column ids, outermost first).
onGroupingChange(next: GroupingState) => void
expandedExpandedStateControlled expansion of group / tree / detail rows (`true` = all).
onExpandedChange(next: ExpandedState) => void
getSubRows(row: TData, index: number) => readonly TData[] | undefinedTree data: a row's children. Parents get an expander in their first cell and children indent under them; filters keep a parent whose children match.
renderDetail(row: Row<TData>) => ReactNodeMaster / detail: content shown under a row when it is expanded (every row gets an expander in its first cell). Not with `enableRowVirtualization`.
showTotalsbooleanA totals row under the body: each column with `meta.aggregate` summarised over every row that passes the filters (not just the page).
onChartRange(range: DataTableChartRange) => voidGrid mode: adds "Chart selection" to the cell context menu. Receives the selected range as columns and rows of raw values — hand it to a chart (e.g. charts' AutoChart); the grid itself never draws one.
flashChangesbooleanLive data: when `data` changes, cells whose value changed flash briefly — green when a number rose, red when it fell, amber otherwise. Rows are matched by `getRowId`; only rows whose object changed are compared, so immutable updates of a few rows stay cheap on 100k-row tables.
onLoadMore() => voidInfinite loading: called when the last rows scroll into view while `hasMore` is true (and `loadingMore` is not). Append the next page to `data`; skeleton rows show under the table while `loadingMore`.
hasMoreboolean
loadingMoreboolean
enableColumnVirtualizationbooleanWide tables: render only the unpinned columns in (or near) the viewport, so hundreds of columns scroll as smoothly as ten. Needs explicit widths (`enableColumnResizing`) and a single header row; pinned columns always render. Keyboard navigation, find and ranges scroll hidden columns into view.
columnOrderColumnOrderStateControlled column order (leaf column ids).
onColumnOrderChange(next: ColumnOrderState) => void
cellSelectionDataTableCellSelectionControlled cell ranges (`interaction="grid"`).
onCellSelectionChange(next: DataTableCellSelection) => void
emptyMessageReactNodeMessage shown when there are no rows and not loading.
classNamestring

Also accepts: Omit< DataTableProps<TData, TValue>, "interaction" >