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

States

Props

PropTypeDefaultDescription
virtualizedbooleanfalseRender only the visible row window for large datasets.
rowHeightnumberRow 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.
windowSizenumberRows rendered per window. Derived from the scroller height when omitted; set it only to render a fixed count regardless of height.
overscannumber3Extra rows rendered above/below the window to smooth scrolling.
columnsColumn<Row>[]The column definitions describing each table column.
rowsRow[]The row objects to render — one table row per array element.
getRowId(row: Row) => stringReturns a stable unique id for a row.
sortSortStateThe controlled sort state.
defaultSortSortStateThe 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) => voidCalled with the new sort state when it changes.
searchablebooleanfalseWhen 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.
filtersColumnFiltersPer-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).
defaultFiltersColumnFiltersInitial per-column filter values (uncontrolled).
onFiltersChange(filters: ColumnFilters) => voidCalled with the full filter map whenever any column filter changes.
noResultsStateReactNodeShown instead of emptyState when there are rows but the search or filters match none of them.
toolbarReactNodeExtra 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.
columnStateColumnStateUser-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.
defaultColumnStateColumnStateInitial column layout (uncontrolled).
onColumnStateChange(state: ColumnState) => voidCalled with the full column layout whenever the user changes it.
columnSettingsColumnSettingsWhich 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.
serverDataTableServerServer-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.
multiSortbooleanfalseAllow 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.
stateKeystringRemember 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'rowHow 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) => voidCommits 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.
groupBystring | 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.
totalsbooleanfalseShow 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.
columnGroupsColumnGroup[]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.
exportableboolean | { filename?: string }falseAn "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) => ReactNodeRenders 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.
zebrabooleanfalseWhen true, applies alternating row striping.
stickyHeaderbooleanfalseWhen true, the header stays fixed while the body scrolls.
loadingbooleanfalseWhen true, shows a loading state.
emptyStateReactNodeContent shown when there are no rows.
ariaLabelstringInvisible accessible name for the table, used when there is no visible `title`. A table with neither is an unnamed landmark; dev-warns.
titlestringVisible caption above the table; it also becomes the table's accessible name.
descriptionstringSupporting description text.
labelsDataTableLabelsOverrides for the component’s user-visible strings (i18n).
classNamestringAdditional CSS class names merged onto the root element.

Design tokens

When to use

When not to use

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

← Back to docs