Data table
A dense, sortable, selectable table built on TanStack Table v9. Columns are authored as TanStack column definitions (use createDataTableColumnHelper for value inference). Sorting, selection, global search and column filters all run through the table's row model, and the composites Flab is built from — avatars, status pills, inline meters — live inside cells.
Sortable and selectable
Ada Okaforada@northwind.io | active | |
Bruno Satobruno@northwind.io | invited | |
Carmen Diazcarmen@northwind.io | suspended | |
Dae-jung Parkdae@northwind.io | active | |
Emeka Obiemeka@northwind.io | invited |
const column = createDataTableColumnHelper<Row>();
const columns: DataTableColumn<Row>[] = [ column.accessor((r) => r.name, { id: 'name', header: 'Member', cell: ({ row }) => <CellAvatar name={row.original.name} secondary={row.original.email} />, }), column.accessor((r) => r.status, { id: 'status', header: 'Status', cell: ({ row }) => <CellBadge tone={statusTone[row.original.status]} label={row.original.status} dot />, }), column.accessor((r) => r.usage, { id: 'usage', header: 'Usage', meta: { align: 'right', width: '12rem' }, cell: ({ row }) => <CellMeter value={row.original.usage} label={`${row.original.usage}%`} />, }),];
<DataTable data={rows} columns={columns} selectable getRowId={(r) => r.email} />Multi-column sorting
Every column sorts on click, cycling ascending → descending → off. Hold Shift while clicking a second header to sort by more than one column; the click order sets the priority.
Ada Okaforada@northwind.io | active |
Bruno Satobruno@northwind.io | invited |
Carmen Diazcarmen@northwind.io | suspended |
Dae-jung Parkdae@northwind.io | active |
Emeka Obiemeka@northwind.io | invited |
// Shift-click a second header to append it as a secondary sort.<DataTable data={rows} columns={columns} getRowId={(r) => r.email} />Search, filters and toolbar
A global search box, per-column filter controls, and a right-aligned actions slot. Each column declares its control through meta.filter: text, select, multi-select, or a number range. The multi-select control comes with a search box and a select-all toggle, so long option lists stay manageable.
Ada Okaforada@northwind.io | active |
Bruno Satobruno@northwind.io | invited |
Carmen Diazcarmen@northwind.io | suspended |
Dae-jung Parkdae@northwind.io | active |
Emeka Obiemeka@northwind.io | invited |
const filterColumns: DataTableColumn<Row>[] = [ column.accessor((r) => r.name, { id: 'name', header: 'Member', meta: { filter: { variant: 'text', label: 'Filter members' } }, cell: ({ row }) => <CellAvatar name={row.original.name} secondary={row.original.email} />, }), column.accessor((r) => r.status, { id: 'status', header: 'Status', meta: { filter: { variant: 'multi-select', label: 'Status', placeholder: 'All statuses', options: [ { label: 'Active', value: 'active' }, { label: 'Invited', value: 'invited' }, { label: 'Suspended', value: 'suspended' }, ] } }, cell: ({ row }) => <CellBadge tone={statusTone[row.original.status]} label={row.original.status} dot />, }), column.accessor((r) => r.usage, { id: 'usage', header: 'Usage', meta: { align: 'right', filter: { variant: 'number-range', label: 'Usage' } }, cell: ({ row }) => <CellMeter value={row.original.usage} label={`${row.original.usage}%`} />, }),];
<DataTable data={rows} columns={filterColumns} getRowId={(r) => r.email} searchable toolbarActions={<Button variant="secondary" size="sm"><Download className="size-3.5" /> Export</Button>}/>Column header filters
The same meta.filter controls can live on the column headers instead of the toolbar, AG-Grid style. Set filterPlacement="header" and each filterable column gets a funnel button that opens its control in a popover; the funnel fills in when that column is actively filtered.
Ada Okaforada@northwind.io | active |
Bruno Satobruno@northwind.io | invited |
Carmen Diazcarmen@northwind.io | suspended |
Dae-jung Parkdae@northwind.io | active |
Emeka Obiemeka@northwind.io | invited |
// Same filterColumns as above (each declares meta.filter).<DataTable data={rows} columns={filterColumns} getRowId={(r) => r.email} filterPlacement="header"/>Client pagination
Pass paginated to page a local array. The pager below the table has a page-size select and first / previous / next / last controls; filtering resets to the first page.
Member 01member1@northwind.io | active |
Member 02member2@northwind.io | invited |
Member 03member3@northwind.io | suspended |
Member 04member4@northwind.io | active |
Member 05member5@northwind.io | invited |
<DataTable data={manyRows} columns={columns} getRowId={(r) => r.email} paginated pageSize={5} />Server-side (manual) mode
For server-driven data, set manualPagination / manualSorting and hand the table only the current page plus a rowCount. The table renders what it is given and reports sort and page changes so you can refetch.
Member 01member1@northwind.io | active |
Member 02member2@northwind.io | invited |
Member 03member3@northwind.io | suspended |
Member 04member4@northwind.io | active |
Member 05member5@northwind.io | invited |
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 });const [sorting, setSorting] = useState([]);// Fetch the page from your API whenever pagination/sorting change.const { pageRows, total } = useServerData({ pagination, sorting });
<DataTable data={pageRows} columns={columns} getRowId={(r) => r.email} paginated manualPagination manualSorting rowCount={total} pagination={pagination} onPaginationChange={setPagination} sorting={sorting} onSortingChange={setSorting}/>Virtualized (large data)
Pass virtualized with a maxHeight to window the rows: the table scrolls smoothly over very large datasets while only the visible slice stays in the DOM. This example renders 10,000 rows. Virtualization is an alternative to paginated — set one or the other, not both. Sorting, selection, filters and pinned columns keep working.
// 10,000 rows, but only the visible window is rendered.<DataTable data={hugeRows} columns={columns} getRowId={(r) => r.email} selectable virtualized maxHeight={420}/>Column visibility
Pass viewOptions for a “Columns” control that toggles which columns show. Set enableHiding: false on a column to keep it always visible. Visibility is controllable via columnVisibility.
Ada Okaforada@northwind.io | active |
Bruno Satobruno@northwind.io | invited |
Carmen Diazcarmen@northwind.io | suspended |
Dae-jung Parkdae@northwind.io | active |
Emeka Obiemeka@northwind.io | invited |
<DataTable data={rows} columns={columns} getRowId={(r) => r.email} viewOptions />Column layout: pin, resize, reorder
Pin a column with meta.pinned so it stays in view while scrolling sideways; turn on resizableColumns for drag handles and reorderableColumns to drag headers into a new order. A minWidth keeps the table wide enough to scroll.
Drag a column border to resize it, or double-click the border to snap the column to the width of its widest visible cell. The same best-fit sizing is available imperatively through apiRef as autosizeColumn(id) and autosizeColumns().
const columns: DataTableColumn<Row>[] = [ column.accessor((r) => r.name, { id: 'name', header: 'Member', meta: { pinned: 'left' }, cell: ({ row }) => <CellAvatar name={row.original.name} secondary={row.original.email} />, }), // …status, usage];
<DataTable data={rows} columns={columns} getRowId={(r) => r.email} resizableColumns reorderableColumns minWidth="48rem" />Grouped headers
Nest columns under a group to render a spanning header row.
| Member | ||
|---|---|---|
82% | ||
12% | ||
47% | ||
68% | ||
5% | ||
const columns = [ column.group({ id: 'member', header: 'Member', columns: column.columns([ column.accessor((r) => r.name, { id: 'name', header: 'Name' }), column.accessor((r) => r.status, { id: 'status', header: 'Status' }), ]), }), column.accessor((r) => r.usage, { id: 'usage', header: 'Usage' }),];Row grouping and subtotals
Set groupBy to a column id to group rows under collapsible headers. A column with meta.aggregate (here 'sum') shows a per-group subtotal, formatted through its valueFormatter. Groups start expanded; click a group to collapse it. Use renderGroupHeader for a full-width custom summary row.
| $20,000 | ||
| Ada Okafor | $12,000 | |
| Bruno Sato | $8,000 | |
| $21,000 | ||
| Carmen Diaz | $15,000 | |
| Dae-jung Park | $6,000 | |
| $3,000 | ||
| Emeka Obi | $3,000 |
const groupingColumns: DataTableColumn<Deal>[] = [ dealColumn.accessor((d) => d.stage, { id: 'stage', header: 'Stage' }), dealColumn.accessor((d) => d.owner, { id: 'owner', header: 'Owner' }), dealColumn.accessor((d) => d.value, { id: 'value', header: 'Value', meta: { align: 'right', aggregate: 'sum', valueFormatter: (v) => `$${Number(v).toLocaleString()}` }, }),];
<DataTable data={deals} columns={groupingColumns} groupBy="stage" getRowId={(d) => d.id} />Empty and loading
| No members yet. | ||
<DataTable data={[]} columns={columns} isLoading /><DataTable data={[]} columns={columns} empty="No members yet." />Error
| Couldn't load members. Retry. | ||
<DataTable data={[]} columns={columns} error="Couldn't load members. Retry." />Props
| Prop | Type | Default | Notes |
|---|---|---|---|
data | TData[] | — | Row objects. |
columns | DataTableColumn<TData>[] | — | TanStack column definitions. |
selectable | boolean | false | Adds a selection checkbox column. |
rowSelection / onRowSelectionChange | RowSelectionState | — | Controlled selection keyed by row id; uncontrolled by default. |
getRowId | (row, i) => string | — | Stable row id; defaults to the index. |
sorting / onSortingChange | SortingState | — | Controlled sort; uncontrolled by default. onSortingChange fires either way. |
enableMultiSort | boolean | true | Allow Shift-click to sort by multiple columns. |
searchable / searchPlaceholder | boolean / string | — | Shows a global search box in the toolbar. |
globalFilter / onGlobalFilterChange | string | — | Controlled global search text. |
columnFilters / onColumnFiltersChange | ColumnFiltersState | — | Controlled column filters. Declare controls via column meta.filter. |
filterPlacement | 'toolbar' | 'header' | 'toolbar' | Render meta.filter controls in the toolbar, or as funnel popovers on column headers. |
viewOptions | boolean | false | Adds a "Columns" popover to toggle column visibility. |
columnVisibility / onColumnVisibilityChange | ColumnVisibilityState | — | Controlled column visibility; uncontrolled by default. |
toolbar / toolbarActions / renderToolbar | boolean / ReactNode / (table) => ReactNode | — | Toggle, add actions to, or fully replace the toolbar (DataTableToolbar). |
resizableColumns / reorderableColumns | boolean | — | Drag to resize (double-click a border to best-fit) / drag headers to reorder. |
columnPinning / columnSizing / columnOrder | controlled state | — | Controlled layout; initial pinning also via column meta.pinned. |
apiRef | { current: DataTableApi } | — | Imperative Grid/Column API: pin, reorder, resize, column state, grouping. |
groupBy / grouping / onGroupingChange | string | string[] / GroupingState | — | Groups rows by column id(s) into collapsible sections. |
expanded / defaultExpanded / onExpandedChange | ExpandedState | — | Controls which groups are open. Defaults to all expanded. |
renderGroupHeader | (ctx) => ReactNode | — | Full-width custom group header; ctx has leafRows, count, toggle, colSpan. |
meta.valueFormatter | (value) => ReactNode | — | Formats a cell for display when no cell renderer is given. |
meta.aggregate | 'sum' | 'avg' | 'min' | 'max' | 'count' | 'extent' | — | Per-group subtotal for the column when grouped. |
paginated / pageSize / pageSizeOptions | boolean / number / number[] | — | Enables client pagination and renders a pager. |
pagination / onPaginationChange | PaginationState | — | Controlled pagination; uncontrolled by default. |
manualPagination / manualSorting / manualFiltering | boolean | — | Server-side mode: the caller supplies each processed page. |
rowCount / pageCount | number | — | Total rows/pages for manual pagination. pageCount -1 if unknown. |
virtualized | boolean | false | Windows rows for large data (TanStack Virtual). Needs maxHeight; excludes paginated. |
maxHeight | number | string | — | Viewport height for the scroll container. Required when virtualized. |
estimatedRowHeight / overscan | number | 36 / 8 | Virtualizer row-height estimate and rows rendered beyond the viewport. |
isLoading | boolean | — | Shows shimmer rows. |
error / empty | ReactNode | — | Error and empty states. |