TanStack Table is a headless data-grid engine. It computes sorting, filtering, pagination and row selection; you render the result. ZUI's Table provides the styled Table, TableRow, TableHead and TableCell elements — and already ships a data-state="selected" row style, so selection lights up for free.

Click a column header to sort, type in the filter, page through the rows, or tick a row to select it — the demo below is live.

Install

npm install @tanstack/react-table

Define data and columns

Describe your row shape and build columns with createColumnHelper. A display column with no accessor is the idiomatic way to add a selection checkbox; an accessor column with a custom cell renders a ZUI Badge.

import { createColumnHelper } from '@tanstack/react-table'
import { Badge, Checkbox } from '@mrmartineau/zui/react'

interface Person {
  id: number
  name: string
  email: string
  role: string
  status: 'Active' | 'Away' | 'Inactive'
  amount: number
}

const STATUS_COLOR = { Active: 'green', Away: 'amber', Inactive: 'gray' } as const

const columnHelper = createColumnHelper<Person>()

const columns = [
  columnHelper.display({
    id: 'select',
    header: ({ table }) => (
      <Checkbox
        aria-label="Select all rows"
        checked={table.getIsAllPageRowsSelected()}
        onChange={table.getToggleAllPageRowsSelectedHandler()}
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        aria-label={`Select ${row.original.name}`}
        checked={row.getIsSelected()}
        onChange={row.getToggleSelectedHandler()}
      />
    ),
    enableSorting: false,
  }),
  columnHelper.accessor('name', { header: 'Name' }),
  columnHelper.accessor('email', { header: 'Email' }),
  columnHelper.accessor('role', { header: 'Role' }),
  columnHelper.accessor('status', {
    header: 'Status',
    cell: (info) => {
      const status = info.getValue()
      return <Badge color={STATUS_COLOR[status]}>{status}</Badge>
    },
  }),
  columnHelper.accessor('amount', {
    header: 'Amount',
    cell: (info) =>
      info.getValue().toLocaleString('en-US', { style: 'currency', currency: 'USD' }),
  }),
]

Create the table instance

Enable the row models for the features you want. Holding sorting, globalFilter and rowSelection in React state keeps the grid fully controlled.

import { useState } from 'react'
import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from '@tanstack/react-table'

const [sorting, setSorting] = useState([])
const [globalFilter, setGlobalFilter] = useState('')
const [rowSelection, setRowSelection] = useState({})

const table = useReactTable({
  data,
  columns,
  state: { sorting, globalFilter, rowSelection },
  onSortingChange: setSorting,
  onGlobalFilterChange: setGlobalFilter,
  onRowSelectionChange: setRowSelection,
  enableRowSelection: true,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  initialState: { pagination: { pageSize: 5 } },
})

Render the header with sorting

Map over the header groups into ZUI's TableHead. Wrap sortable headers in a button calling getToggleSortingHandler(), set aria-sort for assistive tech, and show a Phosphor caret for the current direction.

import { flexRender } from '@tanstack/react-table'
import { TableHead, TableHeader, TableRow } from '@mrmartineau/zui/react'

const SORT_ICON = {
  asc: 'ph ph-arrow-up',
  desc: 'ph ph-arrow-down',
  false: 'ph ph-arrows-down-up',
}

<TableHeader>
  {table.getHeaderGroups().map((headerGroup) => (
    <TableRow key={headerGroup.id}>
      {headerGroup.headers.map((header) => {
        const sorted = header.column.getIsSorted()
        return (
          <TableHead
            key={header.id}
            aria-sort={
              sorted === 'asc'
                ? 'ascending'
                : sorted === 'desc'
                  ? 'descending'
                  : undefined
            }
          >
            {header.column.getCanSort() ? (
              <button
                type="button"
                className="zui-table-sort"
                onClick={header.column.getToggleSortingHandler()}
              >
                {flexRender(header.column.columnDef.header, header.getContext())}
                <i className={SORT_ICON[String(sorted)]} aria-hidden="true" />
              </button>
            ) : (
              flexRender(header.column.columnDef.header, header.getContext())
            )}
          </TableHead>
        )
      })}
    </TableRow>
  ))}
</TableHeader>

zui-table-sort is your own class — the trigger is a plain button, so style it however suits your design. The demo uses a borderless inline button:

.zui-table-sort {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3xs);
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

.zui-table-sort i {
  opacity: 0.4;
}

[aria-sort] .zui-table-sort i {
  opacity: 1;
}

Render the body with selection

Set data-state="selected" on the TableRow and ZUI's existing selected-row style applies — no extra CSS.

import { flexRender } from '@tanstack/react-table'
import { TableBody, TableCell, TableRow } from '@mrmartineau/zui/react'

<TableBody>
  {table.getRowModel().rows.map((row) => (
    <TableRow
      key={row.id}
      data-state={row.getIsSelected() ? 'selected' : undefined}
    >
      {row.getVisibleCells().map((cell) => (
        <TableCell key={cell.id}>
          {flexRender(cell.column.columnDef.cell, cell.getContext())}
        </TableCell>
      ))}
    </TableRow>
  ))}
</TableBody>

Filtering and pagination

The globalFilter state binds to a ZUI Input; pagination uses the table's helpers with ZUI Buttons.

import { Button, Input } from '@mrmartineau/zui/react'

<Input
  type="search"
  placeholder="Filter people…"
  value={globalFilter}
  onChange={(event) => setGlobalFilter(event.target.value)}
/>

<Button
  variant="outline"
  size="sm"
  disabled={!table.getCanPreviousPage()}
  onClick={() => table.previousPage()}
>
  Previous
</Button>
<Button
  variant="outline"
  size="sm"
  disabled={!table.getCanNextPage()}
  onClick={() => table.nextPage()}
>
  Next
</Button>

Notes

Theme

Copy this CSS to your project: