DataTable
Signal-driven data table with client/server sort, filter, pagination, multi-selection, expandable rows, and CSS content-visibility row containment for large datasets
Category: display · WCAG 2.2-AA · table, data, grid, sort, filter, pagination, selection
Sizes
- compact
- normal
- relaxed
States
- default
- loading
- empty
Props
| Prop | Type | Default | Description |
|---|---|---|---|
virtualized | boolean | false | Render only the visible row window for large datasets. |
rowHeight | number | — | Row height in px for the virtualized window. Measured from the first rendered row when omitted, so the density presets stay correct; set it only for custom-sized rows. |
windowSize | number | — | Rows rendered per window. Derived from the scroller height when omitted; set it only to render a fixed count regardless of height. |
overscan | number | 3 | Extra rows rendered above/below the window to smooth scrolling. |
columns | Column<Row>[] | — | The column definitions describing each table column. |
rows | Row[] | — | The row objects to render — one table row per array element. |
getRowId | (row: Row) => string | — | Returns a stable unique id for a row. |
sort | SortState | — | The controlled sort state. |
defaultSort | SortState | — | The initial sort state when uncontrolled. |
sortMode | 'client' | 'server' | 'client' | Whether sorting is handled client-side or by the server ('client' | 'server'). |
onSortChange | (sort: SortState | undefined) => void | — | Called with the new sort state when it changes. |
searchable | boolean | false | When true, shows a search/filter input. |
pagination | { pageSize: number; pageSizeOptions?: number[]; page?: number; onPageChange?: (page: number) => void } | — | Paging config: pageSize, optional pageSizeOptions, and page/onPageChange to control the current page. With `server`, it is the pager for server-side paging. |
selection | { mode: 'single' | 'multi'; selected?: string[]; onChange?: (ids: string[]) => void } | — | Row-selection configuration (mode and selected ids). |
batchActions | { label: string; onClick: (selectedIds: string[]) => void }[] | — | Actions applied to the currently selected rows. |
filters | ColumnFilters | — | Per-column filter values (controlled), keyed by column key. Columns opt in with `Column.filter`: `text` (substring), `select` (faceted checklist with counts), `range` (numeric min/max). |
defaultFilters | ColumnFilters | — | Initial per-column filter values (uncontrolled). |
onFiltersChange | (filters: ColumnFilters) => void | — | Called with the full filter map whenever any column filter changes. |
noResultsState | ReactNode | — | Shown instead of emptyState when there are rows but the search or filters match none of them. |
toolbar | ReactNode | — | Extra controls rendered in the toolbar next to the search box — exports, primary actions. |
rowActions | (row: Row) => RowAction<Row>[] | — | Per-row actions. Returns the menu entries for a row; rendered as a trailing overflow-menu column. Each entry has id, label, onSelect(row), and optional destructive/disabled/icon. |
columnState | ColumnState | — | User-adjustable column layout (controlled): `hidden` keys, display `order`, explicit `widths` in px, and `pinned` sides. One object, so it round-trips through storage or a URL as a unit. |
defaultColumnState | ColumnState | — | Initial column layout (uncontrolled). |
onColumnStateChange | (state: ColumnState) => void | — | Called with the full column layout whenever the user changes it. |
columnSettings | ColumnSettings | — | Which column-layout controls to offer: `visibility` (a "Columns" menu in the toolbar), `resizable` (a drag handle per header; arrows nudge, Home resets), `reorderable` (Move left/right in the header menu), `pinnable` (Pin to start/end in the header menu). All off by default. |
server | DataTableServer | — | Server-driven mode: rows are rendered as the current page verbatim and `onQueryChange({ sort, search, filters, page, pageSize })` fires whenever any of them changes (not on mount). `totalItems` drives the pager. One switch turns off client sort, search, filters and paging together. |
multiSort | boolean | false | Allow sorting by more than one column: Shift-click a header adds it as a tie-breaker (`SortState.thenBy`); a plain click replaces the whole sort. Sorted headers show their level. |
stateKey | string | — | Remember the user's column layout and sort across reloads, in local storage under this key. Applies to uncontrolled `columnState`/`sort`; controlled props still win. Tables sharing a key share the preference. |
keyboardNavigation | 'row' | 'grid' | row | How the keyboard moves through the table. 'row' keeps every control in the Tab order with the arrows stepping between them. 'grid' is the APG data-grid pattern: one Tab stop, arrows move a focused cell, Home/End within the row, Ctrl+Home/End to the corners, PageUp/PageDown by a screenful, Enter/F2 enters the cell's control, Escape returns to the cell; rows outside the virtualized window are scrolled to. |
onCellEdit | (row: Row, key: string, value: string) => void | — | Commits an inline edit with the row, the column key and the new text. Enables editing for every column marked `editable`; the table does not mutate `rows` itself. |
groupBy | string | string[] | — | Group the rows by one or more columns, in order. Each group is a collapsible row showing its value, its row count and every `aggregate` column's reduction; leaves keep the current sort inside their group. Groups appear in order of first occurrence — sort by the grouped column to order them. |
totals | boolean | false | Show a totals row under the body with each `aggregate` column's reduction over every row passing the search and filters (not just the page). Sticks to the bottom of the scroller. |
pinnedRows | { top?: Row[]; bottom?: Row[] } | — | Rows kept in view outside sort, search, filters, paging and the virtual window: `top` rows sit under the header (stuck there with `stickyHeader`), `bottom` rows above the totals. |
columnGroups | ColumnGroup[] | — | Bands of columns under a shared header, rendered as a row above the column headers. Keep a band's columns adjacent; reordering them apart splits the band. |
exportable | boolean | { filename?: string } | false | An "Export CSV" button in the toolbar: every row passing the search and filters (all pages; with `server`, the rows given), in the current sort, visible columns as headers, raw cell values as fields (RFC 4180, UTF-8 with BOM). Pass `{ filename }` to name the file; it defaults to the `title`. |
renderExpandedRow | (row: Row) => ReactNode | — | Renders the expanded content for a row. |
density | 'compact' | 'normal' | 'relaxed' | 'normal' | Row density — 'compact', 'normal', or 'relaxed'. ⚠ It sets a row **height floor**, so it is invisible whenever the cell content is already taller: a two-line cell or a Badge stack looks identical at every density. Reported as "barely distinguishable" — the prop works, the content is winning. Shrink the cell content, or set `--cascivo-data-table-cell-gap` to tighten the horizontal rhythm too. |
zebra | boolean | false | When true, applies alternating row striping. |
stickyHeader | boolean | false | When true, the header stays fixed while the body scrolls. |
loading | boolean | false | When true, shows a loading state. |
emptyState | ReactNode | — | Content shown when there are no rows. |
ariaLabel | string | — | Invisible accessible name for the table, used when there is no visible `title`. A table with neither is an unnamed landmark; dev-warns. |
title | string | — | Visible caption above the table; it also becomes the table's accessible name. |
description | string | — | Supporting description text. |
labels | DataTableLabels | — | Overrides for the component’s user-visible strings (i18n). |
className | string | — | Additional CSS class names merged onto the root element. |
Design tokens
--cascivo-color-surface--cascivo-color-bg-subtle--cascivo-color-border--cascivo-color-border-strong--cascivo-color-text--cascivo-color-text-muted--cascivo-color-accent--cascivo-font-sans--cascivo-text-sm--cascivo-text-xs--cascivo-font-semibold--cascivo-font-medium--cascivo-radius-lg--cascivo-radius-sm--cascivo-space-2--cascivo-space-3--cascivo-space-4--cascivo-data-table-max-height--cascivo-data-table-cell-gap--cascivo-duration-150--cascivo-duration-500--cascivo-ease-out--cascivo-ease-in-out
When to use
- Displaying tabular data with columns the user sorts, filters, or pages through
- Selecting rows for batch actions across a dataset
- Rendering large datasets that benefit from row containment and server-side sort/paging
When not to use
- A simple static list of items — use List
- Layout grids of cards or media — use a Card grid, not a data table
How to build an accessible DataTable in React →
Examples
Basic table
<DataTable
columns={[
{ key: 'name', header: 'Name', sortable: true },
{ key: 'role', header: 'Role' },
]}
rows={[
{ name: 'Alice', role: 'Engineer' },
{ name: 'Bob', role: 'Designer' },
]}
getRowId={(r) => r.name}
/>Custom cell content with Column.render
Use Column.render to return any ReactNode per cell — a Badge for status, an icon + link for a repo, a right-aligned timestamp. Columns without render fall back to String(row[key]).
<DataTable
columns={[
{ key: 'name', header: 'Project', sortable: true },
{
key: 'status',
header: 'Status',
render: (row) => (
<Badge variant={row.status === 'ready' ? 'success' : 'warning'}>{row.status}</Badge>
),
},
{
key: 'updated',
header: 'Updated',
align: 'end',
render: (row) => new Date(row.updated).toLocaleDateString(),
},
]}
rows={rows}
getRowId={(r) => r.name}
/>Full-featured: selection, pagination, search
<DataTable
columns={columns}
rows={rows}
getRowId={(r) => r.id}
searchable
pagination={{ pageSize: 10, pageSizeOptions: [10, 25, 50] }}
selection={{ mode: 'multi', onChange: setSelected }}
batchActions={[{ label: 'Delete', onClick: deleteRows }]}
stickyHeader
zebra
/>Filters, row actions and a columns menu
Per-column filters under the header (a text input, a faceted checklist with counts, a numeric range), a row actions menu, and a toolbar Columns menu to hide columns.
<DataTable
columns={[
{ key: 'name', header: 'Name', sortable: true, filter: 'text' },
{ key: 'status', header: 'Status', filter: 'select' },
{ key: 'amount', header: 'Amount', align: 'end', filter: 'range' },
]}
rows={rows}
getRowId={(r) => r.id}
columnSettings={{ visibility: true, resizable: true, reorderable: true, pinnable: true }}
defaultColumnState={{ pinned: { name: 'start' } }}
rowActions={(row) => [
{ id: 'edit', label: 'Edit', onSelect: () => edit(row) },
{ id: 'delete', label: 'Delete', destructive: true, onSelect: () => remove(row) },
]}
exportable={{ filename: 'invoices' }}
ariaLabel="Invoices"
/>Grid keyboard mode with inline editing
One Tab stop; the arrows move a focused cell and Enter or F2 opens the cell for editing. Commit the edit into your own state — the table never mutates `rows`.
<DataTable
columns={[
{ key: 'name', header: 'Name', editable: true },
{ key: 'qty', header: 'Qty', editable: true, align: 'end' },
]}
rows={items}
getRowId={(r) => r.id}
keyboardNavigation="grid"
onCellEdit={(row, key, value) => update(row.id, { [key]: value })}
ariaLabel="Line items"
/>Grouped, with totals and a column band
Rows grouped by region with a count and a sum on each group row, a sticky totals row over everything that passes the filters, and two columns under one "Order" header.
<DataTable
columns={[
{ key: 'region', header: 'Region', sortable: true },
{ key: 'status', header: 'Status', aggregate: 'count' },
{ key: 'amount', header: 'Amount', align: 'end', aggregate: 'sum' },
]}
rows={orders}
getRowId={(o) => o.id}
groupBy="region"
totals
columnGroups={[{ header: 'Order', columns: ['status', 'amount'] }]}
ariaLabel="Orders by region"
/>Server-driven
The server applies sort, search, filters and paging; the table renders the page it is given and reports the query whenever it changes.
<DataTable
columns={columns}
rows={page.rows}
getRowId={(r) => r.id}
searchable
pagination={{ pageSize: 50 }}
server={{ totalItems: page.total, onQueryChange: load }}
ariaLabel="Orders"
/>A million rows
Virtualized: only the visible rows are in the DOM, the scrollbar reaches the last row at any count, and search and sort stay usable. Row height and viewport are measured, so nothing else is needed.
<DataTable
columns={columns}
rows={millionRows}
getRowId={(r) => r.id}
virtualized
searchable
ariaLabel="Events"
/>Related components
- Pagination — DataTable embeds pagination controls for paged data
- EmptyState — Render an EmptyState via the emptyState prop when there are no rows