Atomx Docs
Components

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

Alice Johnson
Emailalice@example.comRoleEngineerStatusactive
Bob Smith
Emailbob@example.comRoleDesignerStatusactive
Carol White
Emailcarol@example.comRoleManagerStatusinactive
David Lee
Emaildavid@example.comRoleEngineerStatusactive
Eva Martinez
Emaileva@example.comRoleEngineerStatusactive
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

6 rows

1 / 2

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

Alice Johnson
Emailalice@example.comRoleEngineerStatusactive
Bob Smith
Emailbob@example.comRoleDesignerStatusactive
Carol White
Emailcarol@example.comRoleManagerStatusinactive
David Lee
Emaildavid@example.comRoleEngineerStatusactive
Eva Martinez
Emaileva@example.comRoleEngineerStatusactive
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

12 rows

1 / 3

Cursor-style (unknown total)

Alice Johnson
Emailalice@example.comRoleEngineerStatusactive
Bob Smith
Emailbob@example.comRoleDesignerStatusactive
Carol White
Emailcarol@example.comRoleManagerStatusinactive
David Lee
Emaildavid@example.comRoleEngineerStatusactive
Eva Martinez
Emaileva@example.comRoleEngineerStatusactive
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

5 rows

1 / 1

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.

ORD-001
CustomerAlice JohnsonDate2026-04-12Total$128.00Statusdelivered
ORD-002
CustomerBob SmithDate2026-04-18Total$34.00Statusshipped
ORD-003
CustomerCarol WhiteDate2026-04-25Total$210.00Statuspending
ORD-004
CustomerDavid LeeDate2026-05-01Total$0.00Statuscancelled
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

4 rows

1 / 1

Install

npx shadcn@latest add https://atomx.acau.net/r/data-table.json

This installs the following packages automatically:

  • @tanstack/react-table — headless table engine
  • @tanstack/react-virtual — row virtualisation
  • react-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

PropTypeDefaultDescription
dataTData[]Row data
columnsColumnDef<TData, any>[]Column definitions
classNamestringClass on the outer wrapper
emptyMessagestring"No results."Empty-state message
loadingbooleanfalseShows spinner overlay
stickyHeaderbooleantrueFixes the header while scrolling
cardBreakpoint"sm" | "md" | "lg"Show card layout below this breakpoint
pageSizeOptionsnumber[][10, 25, 50]Page-size selector options
paginationPaginationStateControlled pagination
onPaginationChange(s: PaginationState) => voidPagination change callback
pageCountnumberTotal page count (server-side)
rowCountnumberTotal row count (server-side, for display)
sortingSortingStateControlled sorting
onSortingChange(s: SortingState) => voidSorting change callback
manualSortingbooleanfalseDisable client-side sort
enableMultiSortbooleanfalseAllow Shift+click multi-sort
globalFilterstringControlled global filter value
onGlobalFilterChange(v: string) => voidGlobal filter change callback
columnFiltersColumnFiltersStateControlled column filters
onColumnFiltersChange(f: ColumnFiltersState) => voidColumn filter change callback
manualFilteringbooleanfalseDisable client-side filter
toolbarSlotReactNodeContent rendered above the table (left side of toolbar)
enableRowSelectionboolean | "single" | ((row) => boolean)Enable row selection
rowSelectionRowSelectionStateControlled selection
onRowSelectionChange(s: RowSelectionState) => voidSelection change callback
renderSubRow(props) => ReactNodeRender expanded sub-row panel
expandedExpandedStateControlled expansion
onExpandedChange(s: ExpandedState) => voidExpansion change callback
getRowCanExpand(row) => booleanHide expand toggle for ineligible rows
columnVisibilityVisibilityStateControlled column visibility
onColumnVisibilityChange(s: VisibilityState) => voidVisibility change callback
enableColumnOrderingbooleanEnable drag-to-reorder column headers
columnOrderColumnOrderStateControlled column order
onColumnOrderChange(s: ColumnOrderState) => voidColumn order change callback
enableRowPinningbooleanfalseEnable row pinning to top/bottom zones
rowPinningRowPinningStateControlled row pinning
onRowPinningChange(s: RowPinningState) => voidRow pinning change callback
editingRowPin"top" | "bottom" | falsefalseAuto-pin editing row
onRowSubmit(row, values, form) => Promise<void> | voidCell edit submission handler
onRowClick(row, event) => voidRow click handler
onRowContextMenu(row, event) => voidRow right-click handler
virtualizedbooleanfalseEnable row virtualisation
rowHeightnumber48Estimated row height (px) for virtualiser

ColumnDef Meta Extensions

FieldTypeDescription
meta.editable(field, row) => ReactNodeRender function for the cell editor
meta.isEditable(row) => booleanConditionally disable editing for a row
meta.onCellClick(row, value, event) => voidCell-level click handler
meta.isPrimaryColumnbooleanShow column prominently in card layout
meta.truncatebooleanSet false to disable cell truncation
meta.sticky"left" | "right"Pin column to left or right edge