import { DataTable } from "@elabs-ai/components-data";npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-dataPaste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.
Add the brand-ui DataTable 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 DataTable up before writing code: `npx -y @elabs-ai/components-cli docs DataTable` 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.
Avoid
- Forking table state into the toolbar — pass the table instance via toolbar={(table) => …}; don't duplicate sorting/filter state.
- Hand-rolling virtualization for >50 rows — the DataTable owns it.
- Status cells as raw colored text — render a Badge so it reads in every theme.
- Decorating a dense table with callouts/hatch — decoration density goes DOWN as information density goes UP.
Works with
Used inside
Pairs with
Examples
Lines
`zebra={false}` opts out of the default gentle zebra striping in favour of the classic line model — a `border-border-strong` divider between rows.
Column Dividers
`columnDividers` adds a quiet `--rule` hairline between columns, in the header and the body. Off by default; pinned columns keep their own seam instead.
Sorted
Filtered
With Toolbar
Paginated
Loading
Shows a spinner overlay when rows are present + loading.
Loading Empty
Shows skeleton placeholder rows when data is empty + loading (initial load).
Loading To Loaded
Toggle between loading (skeleton) and loaded (real data) to verify there is no layout jump when real rows arrive.
Empty
Virtualized 10 K
10 000 generated rows with row virtualization enabled. Only a small window of rows is rendered in the DOM at any time. Scroll the container to verify smooth windowing. (Real perf/smoothness cannot be measured in jsdom — use this story in a browser.)
Server Side
Documented server-side example. Demonstrates the manual* + onServerChange pattern: - `manualPagination`, `manualSorting`, `manualFiltering` are all true. - The component never fetches. `onServerChange` fires when any slice changes. - The story simulates a remote fetch with a setTimeout and updates `data`. - `pageCount` / `rowCount` are passed so TanStack can compute page boundaries. In a real app, replace the setTimeout with your data-fetching hook (e.g. SWR, React Query, or a server action) and remove the simulated data generation.
Saved View Round Trip
Demonstrates saved-view serialize → parse → rehydrate. The user configures sorting + column visibility, clicks "Save view", and the state is JSON-serialized. On remount (simulated by toggling the key), the same state is rehydrated via `initialView` and the table looks identical.
Caption
`caption` gives the table a real accessible name — visually hidden (`sr-only`) but announced by screen readers and exposed as the table's accessible name.
Clickable Rows
`onRowClick` gives each row exactly ONE activation target: a visually-hidden `<button>` in the first cell, named after the row. Pointer clicks anywhere in the row body resolve to the same handler, guarded so a nested control (the "Restart" button) or a text-selection drag never activates the row. `rowClassName` highlights the degraded row without disturbing the zebra stripe.
Single Page No Pager
A table whose rows all fit on one page renders no pagination chrome.
Single Page Pager Forced
`hidePaginationWhenSingle={false}` forces the pager to show even at one page.
Narrow Container Scroll
A many-column table in a narrow container: the plain (non-virtualized) branch scrolls horizontally instead of clipping columns, is keyboard- focusable, and shows a token-driven edge fade once scrolled.
Pinned Columns
Pinned Columns Classic Lines
The same pinning under the classic line model (`zebra={false}`): the frozen cells carry no stripe layer, and the row's `border-border-strong` divider is painted over their opaque fill by the collapsed-border model.
With Column Resizing
`enableColumnResizing` adds a drag handle to the end of every resizable header cell — a WAI-ARIA separator-as-slider, operable by pointer/touch (TanStack's own `getResizeHandler()`) or keyboard (ArrowLeft/ArrowRight while the handle is focused). `columnSizing`/`onColumnSizingChange` make it a controlled slice, exactly like sorting or column pinning; omit them to let the table manage its own widths. A resized column that is ALSO pinned keeps its sticky offset in sync for free — pinning already reads `column.getSize()`.
With Column Resizing Real Double Click
#82 gap 1 — the double-click-reset regression locks in `data-table.test.tsx` use jsdom's `fireEvent.doubleClick`, which dispatches ONLY a synthetic `dblclick` event — never the two real `mousedown`/`mouseup` pairs a browser fires for an actual double click, which `header.getResizeHandler()` (wired to `onMouseDown` on the SAME handle) also listens for. `userEvent.dblClick` DOES synthesize the full mousedown/mouseup ×2 + dblclick sequence, and this play function runs in a real browser (Storybook test runner), so it exercises the actual event path a user's double click takes — and asserts the column lands exactly at its declared size, with no partial resize left behind by whatever `getResizeHandler()`'s pointer-drag state machine did with those two extra mousedown/mouseup pairs.
Column resizing — sort button vs. resize handle
#82 gap 2 — the resize handle's `min(24px, 50%)` hit box sits over the trailing edge of the SAME header cell a sortable column's own toggle button occupies. jsdom's `fireEvent.click` dispatches straight to a target node with no real hit-testing, so the regression lock in `data-table.test.tsx` can only prove the button RESPONDS to a click addressed to it — not that a real screen click at the button's own on-screen coordinates actually resolves to the button rather than the handle. `document.elementFromPoint` is the browser's real hit-test; this play function samples it right at the sort button's own trailing edge (the side nearest the handle) and asserts it resolves inside the button, then performs the click there and asserts the sort actually toggled. Sampled on TWO columns, not one, because the collision is alignment- dependent: a `start`-aligned header's button sits at the LEADING edge, nowhere near the handle, so it can't exercise the collision at all — round 1 review (independent real-Chromium validation) caught exactly this: the original version of this story sampled only "Service" (start-aligned, button ends 293px clear of the handle) and passed trivially, while "Latency (ms)" — numeric, so `meta.numeric` end-aligns it (#69) — pushes the SAME button's trailing edge 12px UNDER the handle's 24px hit box. The "Service" case is kept because a real regression here (e.g. the handle growing) should still be caught on the unproblematic column too.
Row Selection Unselected
`createSelectionColumn()` drops a ready-made checkbox column onto any table: a header select-all (real `indeterminate` for a partial page selection) plus a per-row checkbox. Uncontrolled here — the table owns `rowSelection` internally; pass `rowSelection`/`onRowSelectionChange` to drive it from the app instead.
Row Selection Partial
A controlled, partial selection: the header checkbox reads `indeterminate`.
Row Selection All Selected
A controlled, full-page selection: the header checkbox reads `checked`.
Row Selection With Toolbar
A bulk-action toolbar is not a separate prop — `toolbar={(table) => …}` already hands the live table instance to the render-prop, so a consumer builds "N selected · Clear" from `table.getSelectedRowModel()` the same way `WithToolbar` builds a filter bar from the same instance.
Themed Header And Selection
A theme dials the header row's background/foreground/size/transform/tracking and the row-selection colour through the `--table-header-*` and `--selection*` contract tokens — independently of `--accent`, so a "selected" row can read differently from a hovered/focused one. Every token defaults to today's rendering (see `RowSelectionPartial`); this story sets them on an ancestor `style`, the same seam a theme author dials once in `themes.css`.
Row Reorder
Opt-in row drag-reorder. Fully controlled, like every other DataTable slice: `onRowReorder` reports the move as `(from, to, row)` and this story re-orders its own `data` in response — the component never mutates `data` itself. Try both ways to move a row: drag the grip handle with a mouse, or Tab to it and use the keyboard — 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).
Row Reorder Whole Row
`rowReorderHandle="row"` makes the whole row the drag activator — no extra grip column. Reach for this only when the row has no other primary interaction (no `onRowClick`), since the row itself and a click target would otherwise compete for the same surface.
Heatmap Cells
A 12 × 24 heatmap: one scale shared by every hour column, values hidden visually (still read by screen readers, copy and sort), the header row hidden, and one colour key. Cells have no minimum width, so they shrink with the table. With the header hidden there is no band to click: tab to a column's sort button (it shows itself on focus) and press Enter.
Bar Cells
In-cell bars. `range: "column"` sizes every bar against the column's own maximum (the largest value fills the track); `track` paints the remainder; a negative value grows left of the zero rule in the negative token.
Sparkline And Column Cells
Sparklines and mini columns from the quarter columns. With `range: "column"` every row shares one y scale, so Lyon's and Turku's 700 peaks sit at the same height; the values stay readable to screen readers.
Category Colouring
`colorBy` washes a row (or a cell's text) by a category, from the shared categorical palette. The category is printed too — colour is never the only cue.
Markdown Cells
`meta.markdown` renders a safe inline subset (bold, italics, code, links, superscript and subscript) as elements — never as HTML.
Card Layout Auto
`layout="auto"`: a `<table>` at 450 px and wider, one `<dl>` card per row below it. "Region" sets `showAt={{ base: true, narrow: false }}`, so it drops out under 450 px. Resize the canvas (or use the viewport toolbar) to switch.
Card Layout
`layout="cards"` at every width, with selection. Sorting moves to a sort bar above the cards and works from the keyboard.
Card Layout Virtualized
Cards stay windowed under `enableRowVirtualization` — only the visible cards mount.
Sticky Rows And Ranks
`stickyRows` keeps the "Average" row at the bottom of every page and after every sort; `showRanks` numbers the ordinary rows 1…n in data order, and the rank travels with its row when the table is re-sorted.
Double Header
`mergeEmptyHeaders` spans an ungrouped column's header down through the empty group row, so a two-row header reads as one block.
Compact
`density="compact"` tightens the header and row padding for dense tables.
Exact Search
`searchMode="exact"` matches a whole cell value (case-insensitive) instead of a substring: "lyon" finds Lyon, "ly" finds nothing.
API
DataTable
| Prop | Type | Description |
|---|---|---|
columnsrequired | ColumnDef<TData, TValue>[] | |
datarequired | TData[] | |
toolbar | (table: TanstackTable<TData>) => ReactNode | Render a toolbar above the table; receives the table instance. |
enablePagination | boolean | Enable client-side pagination. |
pageSize | number | |
hidePaginationWhenSingle | boolean | Hide 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). |
globalFilter | string | Controlled 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) => void | Fires when the table requests a global-filter change (e.g. from typeahead). |
sorting | SortingState | Controlled sorting state. When provided the component is sorted-controlled. |
onSortingChange | OnChangeFn<SortingState> | |
columnVisibility | VisibilityState | Controlled column-visibility state. |
onColumnVisibilityChange | OnChangeFn<VisibilityState> | |
columnFilters | ColumnFiltersState | Controlled column-filters state. |
onColumnFiltersChange | OnChangeFn<ColumnFiltersState> | |
pagination | PaginationState | Controlled pagination state. |
onPaginationChange | OnChangeFn<PaginationState> | |
columnPinning | ColumnPinningState | Controlled 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`. |
onColumnPinningChange | OnChangeFn<ColumnPinningState> | |
enableColumnResizing | boolean | Opt 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. |
columnSizing | ColumnSizingState | Controlled 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`. |
onColumnSizingChange | OnChangeFn<ColumnSizingState> | |
rowSelection | RowSelectionState | Controlled 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`. |
onRowSelectionChange | OnChangeFn<RowSelectionState> | |
enableRowSelection | boolean | ((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`. |
enableMultiRowSelection | boolean | Allow 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) => string | Stable 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. |
initialView | Partial<DataTableViewState> | One-shot rehydrate for uncontrolled slices only (ignored for any slice whose corresponding controlled prop is set). Maps to `useReactTable`'s `initialState`. |
manualSorting | boolean | When 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. |
manualFiltering | boolean | When true, filtering is handled by the server. NOTE: a controlled `columnFilters` with `manualFiltering:false` still filters locally. |
manualPagination | boolean | When true, pagination is handled by the server. |
rowCount | number | Total row count — used by the server model so TanStack can derive page count. Required when `manualPagination` is true and `pageCount` is not provided. |
pageCount | number | Total page count — alternative to `rowCount` for server pagination. When both are provided, `pageCount` wins. |
onServerChange | (args: DataTableServerArgs) => void | Fired after any manual-slice change with the current {pagination, sorting, columnFilters, globalFilter}. The component never fetches; the app must re-fetch and update `data`. |
loading | boolean | When true: overlay spinner; on empty+loading show skeleton rows instead of empty message. |
enableRowVirtualization | boolean | Opt-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. |
estimateRowHeight | number | Estimated 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. |
rowHeight | number | Fixed 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. |
overscan | number | Virtualizer overscan (rows rendered above/below the visible window). Default: 8. |
maxBodyHeight | string | CSS max-height of the scroll container in virtualized mode. Default: "32rem". |
loadingRows | number | Number of skeleton placeholder rows to render while loading. Defaults to `pageSize` (non-virtualized) or `min(10, pageSize)` (virtualized). |
zebra | boolean | Gentle 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). |
columnDividers | boolean | Draw 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. |
stickyRows | DataTableStickyRows<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. |
showRanks | boolean | Prepend a 1…n rank column, in `data` order. Sticky rows are not ranked; the rank travels with its row — sorting never renumbers it. |
rankLabel | string | What 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"`. |
mergeEmptyHeaders | boolean | Span 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. |
hideHeader | boolean | Hide 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. |
enableRowReorder | boolean | Opt-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) => void | Fires 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. |
onRowClick | DataTableRowClickHandler<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>) => string | Accessible 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>) => string | Per-row className, merged alongside the existing zebra/line/hover/selected classes via `cn()` (so it can't accidentally clobber them) (#337). |
caption | ReactNode | Accessible 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). |
interaction | DataTableInteraction | `"table"` (default): a document table — sort buttons and row controls are the tab stops. `"grid"`: the WAI-ARIA grid pattern — ONE tab stop, arrow keys move between header and body cells, Shift / Ctrl(⌘) + click or arrows select cell ranges, Ctrl(⌘)+A selects everything and Ctrl(⌘)+C copies the selection as tab-separated text (what the cells display). Use `DataGrid` for the grid preset. |
enableColumnReorder | boolean | Let 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"`. |
enableColumnMenu | boolean | A 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. |
enableContextMenu | boolean | Right-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. |
exportFileName | string | File name (without extension) for "Export to CSV". Default `"export"`. |
showStatusBar | boolean | A 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. |
enableFilterUI | boolean | A 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. |
floatingFilters | boolean | A 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`. |
showFilterChips | boolean | Chips above the table naming every active column filter ("Region: EU, US"), each removable, plus Clear all. Default `false`; `DataGrid` turns it on. |
enableFind | boolean | Grid 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[]) => void | Grid 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. |
enableGrouping | boolean | Row 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. |
grouping | GroupingState | Controlled row grouping (column ids, outermost first). |
onGroupingChange | (next: GroupingState) => void | |
expanded | ExpandedState | Controlled expansion of group / tree / detail rows (`true` = all). |
onExpandedChange | (next: ExpandedState) => void | |
getSubRows | (row: TData, index: number) => readonly TData[] | undefined | Tree 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>) => ReactNode | Master / detail: content shown under a row when it is expanded (every row gets an expander in its first cell). Not with `enableRowVirtualization`. |
showTotals | boolean | A 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) => void | Grid 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. |
flashChanges | boolean | Live 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 | () => void | Infinite 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`. |
hasMore | boolean | |
loadingMore | boolean | |
enableColumnVirtualization | boolean | Wide 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. |
columnOrder | ColumnOrderState | Controlled column order (leaf column ids). |
onColumnOrderChange | (next: ColumnOrderState) => void | |
cellSelection | DataTableCellSelection | Controlled cell ranges (`interaction="grid"`). |
onCellSelectionChange | (next: DataTableCellSelection) => void | |
emptyMessage | ReactNode | Message shown when there are no rows and not loading. |
className | string |
DataTableCard
| Prop | Type | Description |
|---|---|---|
fieldsrequired | readonly DataTableCardField[] | |
lead | ReactNode | Content above the fields (the selection checkbox, the rank, the row action). |
density | "default" | "compact" | `compact` tightens the padding, like the table's `density="compact"`. |
DataTableCardList
DataTableRankCell
| Prop | Type | Description |
|---|---|---|
rank | string |
DataTableRankHeader
| Prop | Type | Description |
|---|---|---|
label | string | The column's accessible NAME, and its `title`. Defaults to the locale seam's `data.table.rankHeader`. The visible glyph stays "#" — it has to, in a 40px column — so the glyph is decorative (`aria-hidden`) and this string is what the column is actually called. Without it the header's accessible name is the single character "#", which says nothing about WHICH position is printed: beside a sorted column the numbers legitimately read 2, 1, 6, 4 and a reader has no way to learn why. `DataTable` prints the same rule visibly, once, above the table (`data-slot="data-table-rank-key"`). |
Theming
The tokens each state resolves to. Change the token and every theme follows.
| State | Token |
|---|---|
| rowHover | hover:bg-foreground/10 (a foreground-tint wash, so it beats the zebra stripe in the same direction on light AND dark) |
| rowSelected | data-[state=selected]:bg-accent |
| header | bg-surface-muted when sticky, else bg-surface-muted/60 + border-b border-border-strong (the header bottom is the SOLE cue between header and first row → the strong rung, #173) |
| rowDivider | zebra={false}: border-b border-border-strong last:border-b-0; zebra rows use bg-table-stripe instead (the stripe replaces the divider) |