Data Table
A feature-rich data table with sorting, filtering, pagination, row selection, expansion, inline editing, row pinning, and virtualisation. Built on TanStack Table v8.
Preview
Name↕ | Email↕ | Role↕ | Status↕ | |
|---|---|---|---|---|
Alice Johnson | alice@example.com | Engineer | active | |
Bob Smith | bob@example.com | Designer | active | |
Carol White | carol@example.com | Manager | inactive | |
David Lee | david@example.com | Engineer | active | |
Eva Martinez | eva@example.com | Engineer | active |
1–5 of 6 rows
Server-side pagination
When your dataset lives on a server, pass controlled pagination state plus onPaginationChange. Provide pageCount and rowCount when the server reports a known total; omit them for cursor-style APIs where the total is unknown — the footer renders ? for the page total and the last-page button is hidden.
Known total
Name↕ | Email↕ | Role↕ | Status↕ |
|---|---|---|---|
Alice Johnson | alice@example.com | Engineer | active |
Bob Smith | bob@example.com | Designer | active |
Carol White | carol@example.com | Manager | inactive |
David Lee | david@example.com | Engineer | active |
Eva Martinez | eva@example.com | Engineer | active |
1–5 of 12 rows
Cursor-style (unknown total)
Name↕ | Email↕ | Role↕ | Status↕ |
|---|---|---|---|
Alice Johnson | alice@example.com | Engineer | active |
Bob Smith | bob@example.com | Designer | active |
Carol White | carol@example.com | Manager | inactive |
David Lee | david@example.com | Engineer | active |
Eva Martinez | eva@example.com | Engineer | active |
1–5 of 5 rows
Row Expansion Demo
Click the arrow on any row to expand an animated sub-row panel. Cancelled orders have no items and cannot be expanded.
Order↕ | Customer↕ | Date↕ | Total↕ | Status↕ | |
|---|---|---|---|---|---|
ORD-001 | Alice Johnson | 2026-04-12 | $128.00 | delivered | |
ORD-002 | Bob Smith | 2026-04-18 | $34.00 | shipped | |
ORD-003 | Carol White | 2026-04-25 | $210.00 | pending | |
ORD-004 | David Lee | 2026-05-01 | $0.00 | cancelled | |
1–4 of 4 rows
Install
npx shadcn@latest add https://atomx.acau.net/r/data-table.jsonThis installs the following packages automatically:
@tanstack/react-table— headless table engine@tanstack/react-virtual— row virtualisationreact-hook-form— per-row form instances for cell editing@radix-ui/react-collapsible— animated sub-row panels
Usage
import { DataTable } from '@/components/ui/data-table'
import type { ColumnDef } from '@tanstack/react-table'
type User = { id: string; name: string; email: string }
const columns: ColumnDef<User>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
]
export default function Page() {
return <DataTable data={users} columns={columns} />
}Features
Pagination
Client-side pagination is automatic. Pass pageSizeOptions to control the page-size selector.
<DataTable
data={users}
columns={columns}
pageSizeOptions={[10, 25, 100]}
/>Server-side pagination — pass controlled pagination state and onPaginationChange together with pageCount (or rowCount):
const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 25 })
<DataTable
data={users}
columns={columns}
pagination={pagination}
onPaginationChange={setPagination}
pageCount={totalPages}
/>Sorting
Single-column sorting is enabled by default. Column headers are clickable; a second click reverses the direction.
Enable multi-column sort (Shift+click for secondary):
<DataTable data={users} columns={columns} enableMultiSort />Disable sorting on a specific column:
const columns: ColumnDef<User>[] = [
{ accessorKey: 'id', header: 'ID', enableSorting: false },
{ accessorKey: 'name', header: 'Name' },
]Server-side sorting:
const [sorting, setSorting] = React.useState<SortingState>([])
<DataTable
data={users}
columns={columns}
sorting={sorting}
onSortingChange={setSorting}
manualSorting
/>Filtering
Global filter — wire an external search input:
const [search, setSearch] = React.useState('')
<DataTable
data={users}
columns={columns}
globalFilter={search}
onGlobalFilterChange={setSearch}
toolbarSlot={
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="h-8 w-64 rounded border px-2 text-sm"
/>
}
/>Per-column filter — set enableColumnFilter: true on a column to render a filter input in its header:
{ accessorKey: 'email', header: 'Email', enableColumnFilter: true }Server-side filtering:
<DataTable
data={users}
columns={columns}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
manualFiltering
/>Row Selection
Single-row selection (no header checkbox):
<DataTable
data={users}
columns={columns}
enableRowSelection="single"
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>Multi-row selection with a header "select all" checkbox:
<DataTable
data={users}
columns={columns}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>Conditionally disable rows from being selected:
<DataTable
data={users}
columns={columns}
enableRowSelection={(row) => !row.original.isLocked}
/>Row Expansion
Pass a renderSubRow render prop. The sub-row is animated open/closed via Radix Collapsible:
<DataTable
data={orders}
columns={columns}
renderSubRow={({ row, colSpan, onClose }) => (
<div className="p-4">
<h3 className="font-medium">Order {row.original.id} details</h3>
<OrderLineItems items={row.original.items} />
<button onClick={onClose}>Close</button>
</div>
)}
/>Hide the expand toggle for ineligible rows:
<DataTable
data={orders}
columns={columns}
renderSubRow={...}
getRowCanExpand={(row) => row.original.items.length > 0}
/>Column Visibility
The "Columns" button in the top-right opens a dropdown to show/hide columns. This works automatically for all columns. Prevent a column from appearing in the menu:
{ accessorKey: 'id', header: 'ID', enableHiding: false }Controlled visibility (e.g., persist to localStorage):
<DataTable
data={users}
columns={columns}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
/>Column Reordering
Enable drag-to-reorder column headers:
<DataTable
data={users}
columns={columns}
enableColumnOrdering
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
/>Drag column headers left or right to rearrange them. Sticky columns (meta.sticky) cannot be reordered.
Column Resizing
All columns are resizable by default — drag the right edge of any column header. Disable for a specific column:
{ accessorKey: 'checkbox', enableResizing: false }Enforce size boundaries:
{ accessorKey: 'name', minSize: 80, maxSize: 400 }Sticky Columns
Pin a column to the left or right edge using meta.sticky:
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
meta: { sticky: 'left' },
},
{ accessorKey: 'email', header: 'Email' },
{
id: 'actions',
header: 'Actions',
meta: { sticky: 'right' },
cell: ({ row }) => <ActionsMenu row={row} />,
},
]Cell Editing
Each row manages its own react-hook-form instance. Enable editing by providing a meta.editable render function that returns the input:
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
meta: {
editable: (field, row) => (
<input
{...field}
className="w-full rounded border px-2 py-1 text-sm"
autoFocus
/>
),
// optionally gate editing per-row
isEditable: (row) => row.original.status !== 'locked',
},
},
]Double-click a cell (or press Enter when focused) to enter edit mode. Press Enter to commit or Escape to cancel.
Handle submission:
<DataTable
data={users}
columns={columns}
onRowSubmit={async (row, values, form) => {
try {
await api.updateUser(row.original.id, values)
} catch (err) {
form.setError('name', { message: 'Name already taken' })
throw err // keeps cell in edit mode
}
}}
/>While the promise is pending the row is dimmed and pointer events are disabled.
Row Interactions
Row click (skips clicks on interactive child elements like buttons and inputs):
<DataTable
data={users}
columns={columns}
onRowClick={(row, event) => {
router.push(`/users/${row.original.id}`)
}}
/>Context menu:
<DataTable
data={users}
columns={columns}
onRowContextMenu={(row, event) => {
event.preventDefault()
openContextMenu({ row, x: event.clientX, y: event.clientY })
}}
/>Per-cell click (stops propagation to the row click handler):
{
accessorKey: 'status',
header: 'Status',
meta: {
onCellClick: (row, value, event) => {
toggleStatus(row.original.id)
},
},
}Row Pinning
Pin rows to top or bottom zones independently of the scroll position:
<DataTable
data={users}
columns={columns}
enableRowPinning
rowPinning={rowPinning}
onRowPinningChange={setRowPinning}
/>Auto-pin the editing row so it stays visible while changes are committed:
<DataTable
data={users}
columns={columns}
enableRowPinning
editingRowPin="top"
onRowSubmit={handleSubmit}
/>Responsive Card Layout
Show a card-stack layout on small screens:
<DataTable
data={users}
columns={columns}
cardBreakpoint="md"
/>Mark a column as the "primary" field — displayed prominently at the top of each card:
{
accessorKey: 'name',
header: 'Name',
meta: { isPrimaryColumn: true },
}Virtualisation
Render tens of thousands of rows smoothly by only mounting visible rows:
<DataTable
data={largeDataset}
columns={columns}
virtualized
rowHeight={48}
/>The scroll container defaults to max-h-[600px]. Override via the className prop to set a different height. When rows are expanded, their sub-row panels are measured by ResizeObserver and the virtual height adjusts accordingly.
Note: Pagination and virtualisation are mutually exclusive — disable pagination when enabling virtualisation.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | — | Row data |
columns | ColumnDef<TData, any>[] | — | Column definitions |
className | string | — | Class on the outer wrapper |
emptyMessage | string | "No results." | Empty-state message |
loading | boolean | false | Shows spinner overlay |
stickyHeader | boolean | true | Fixes the header while scrolling |
cardBreakpoint | "sm" | "md" | "lg" | — | Show card layout below this breakpoint |
pageSizeOptions | number[] | [10, 25, 50] | Page-size selector options |
pagination | PaginationState | — | Controlled pagination |
onPaginationChange | (s: PaginationState) => void | — | Pagination change callback |
pageCount | number | — | Total page count (server-side) |
rowCount | number | — | Total row count (server-side, for display) |
sorting | SortingState | — | Controlled sorting |
onSortingChange | (s: SortingState) => void | — | Sorting change callback |
manualSorting | boolean | false | Disable client-side sort |
enableMultiSort | boolean | false | Allow Shift+click multi-sort |
globalFilter | string | — | Controlled global filter value |
onGlobalFilterChange | (v: string) => void | — | Global filter change callback |
columnFilters | ColumnFiltersState | — | Controlled column filters |
onColumnFiltersChange | (f: ColumnFiltersState) => void | — | Column filter change callback |
manualFiltering | boolean | false | Disable client-side filter |
toolbarSlot | ReactNode | — | Content rendered above the table (left side of toolbar) |
enableRowSelection | boolean | "single" | ((row) => boolean) | — | Enable row selection |
rowSelection | RowSelectionState | — | Controlled selection |
onRowSelectionChange | (s: RowSelectionState) => void | — | Selection change callback |
renderSubRow | (props) => ReactNode | — | Render expanded sub-row panel |
expanded | ExpandedState | — | Controlled expansion |
onExpandedChange | (s: ExpandedState) => void | — | Expansion change callback |
getRowCanExpand | (row) => boolean | — | Hide expand toggle for ineligible rows |
columnVisibility | VisibilityState | — | Controlled column visibility |
onColumnVisibilityChange | (s: VisibilityState) => void | — | Visibility change callback |
enableColumnOrdering | boolean | — | Enable drag-to-reorder column headers |
columnOrder | ColumnOrderState | — | Controlled column order |
onColumnOrderChange | (s: ColumnOrderState) => void | — | Column order change callback |
enableRowPinning | boolean | false | Enable row pinning to top/bottom zones |
rowPinning | RowPinningState | — | Controlled row pinning |
onRowPinningChange | (s: RowPinningState) => void | — | Row pinning change callback |
editingRowPin | "top" | "bottom" | false | false | Auto-pin editing row |
onRowSubmit | (row, values, form) => Promise<void> | void | — | Cell edit submission handler |
onRowClick | (row, event) => void | — | Row click handler |
onRowContextMenu | (row, event) => void | — | Row right-click handler |
virtualized | boolean | false | Enable row virtualisation |
rowHeight | number | 48 | Estimated row height (px) for virtualiser |
ColumnDef Meta Extensions
| Field | Type | Description |
|---|---|---|
meta.editable | (field, row) => ReactNode | Render function for the cell editor |
meta.isEditable | (row) => boolean | Conditionally disable editing for a row |
meta.onCellClick | (row, value, event) => void | Cell-level click handler |
meta.isPrimaryColumn | boolean | Show column prominently in card layout |
meta.truncate | boolean | Set false to disable cell truncation |
meta.sticky | "left" | "right" | Pin column to left or right edge |
Context Menu
A right-click context menu built on Radix UI's ContextMenu primitive, with items, labels, separators, checkboxes, and sub-menus.
Date Picker
A controlled single-date picker — atomx ships it as a block composing Popover, Calendar, and Button so you get a consistent API instead of wiring the primitives together yourself.