Skip to the content

ComponentsData

Data table

Shows records in a table people can sort, filter, page, select, expand, group, edit and rearrange.

Import

import { DataTable, DataTableColumnHeader, DataTableToolbar, DataTablePagination, DataTableRowActions } from "@booleanpress/ui/data-table"

Also install @tanstack/react-table, @tanstack/react-virtual: pnpm add @tanstack/react-table @tanstack/react-virtual

Usage

DataTable takes TanStack Table 9 column definitions and your rows, and turns each feature on with a prop. Install the peers first: npm install @tanstack/react-table @tanstack/react-virtual.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; recipient: string; status: string }

const columns: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "status", header: "Status" },
]

export function Deliveries({ rows }: { rows: Delivery[] }) {
  return (
    <DataTable
      aria-label="Deliveries"
      columns={columns}
      data={rows}
      getRowId={(row) => row.id}
      sorting
      filtering
      pagination
      selection="multiple"
    />
  )
}

Columns. A column is a TanStack column definition: accessorKey or accessorFn for the value, header for the title, cell to draw the value (a badge, a link), size in pixels, enableSorting, enableHiding and the rest. meta holds what the table adds: label (the column's name as text, for menus and announcements), align, headerClassName, cellClassName, filter (the filter row's control), editor and editorOptions (cell editing) and exportValue (the CSV text). createDataTableColumnHelper<Row>() returns TanStack's column helper with the types filled in. Keep columns and data stable: define them outside the component or with useMemo. Give getRowId a stable id, so selection and expansion follow the record and not its position.

Features. sorting (Shift adds a column), filtering (true, "global" for the search field, "columns" for the filter row), pagination (First, Previous, the pages, Next and Last; { pageSizes, range } adds the rows-per-page select, starting at the first size, and the "11–20 of 120" range, showEdges: false leaves out First and Last), selection ("single" radios or "multiple" checkboxes), renderSubRow (rows that expand to show more), getSubRows (a tree of rows), grouping (rows under expandable group rows, with aggregationFn totals), onCellEdit (cells whose column has meta.editor), columnResizing, columnOrdering, columnVisibility, stickyHeader with maxHeight, virtual (only the rows in view are drawn), loading, empty, size, gridlines and striped. toolbar puts your content above the table and exportCsv adds an Export CSV button.

Moving rows. ReorderableDataTable, from its own entry @booleanpress/ui/data-table-reorder, is a DataTable with a drag handle at the start of each row: drag it, or press Space, move with the arrow keys and press Space again. It takes every prop of DataTable; onRowOrderChange (required) receives data in its new order and the move, to pass back as data. It is the only part that needs dnd kit: install @dnd-kit/core (6) and @dnd-kit/sortable (10) to use it.

import { ReorderableDataTable } from "@booleanpress/ui/data-table-reorder"

<ReorderableDataTable aria-label="Connections" columns={columns} data={connections} getRowId={(row) => row.id} onRowOrderChange={setConnections} />

State. Every slice is uncontrolled until you control it, as in TanStack Table: initialState sets where it starts; state={{ sorting }} with onSortingChange controls one slice (the same for columnFilters, globalFilter, pagination, rowSelection, expanded, grouping, columnSizing, columnOrder, columnVisibility and columnPinning). Pin a column with columnPinning: { start: ["name"] } and it stays in view while the table scrolls sideways.

Server. manualSorting, manualFiltering and manualPagination hand the work to your server: control the slices, fetch when they change, pass the page as data and the total as rowCount, and set loading while the request runs. Go back to the first page when a filter changes (the table does this itself only when it pages), and drop an answer that arrives after a newer request was sent, so a slow answer never replaces a newer page (the Server mode example cancels it). Selection is kept by id across pages, so the selected count includes rows on other pages; take an id out of rowSelection when its record is deleted.

Your own table. useDataTable takes the same props and returns the TanStack table instance, for a layout DataTable does not draw. Render each header with DataTableColumnHeader (sort button, aria-sort, column menu, resize handle) and each row's cells with the Table parts, and add DataTableToolbar, DataTablePagination and DataTableRowActions. Row reordering moves rows only in DataTable. exportToCsv(table) returns the filtered rows of every page, in the order shown, as CSV text ({ rows: "page" } or "selected" for fewer), and downloadCsv(text, "name.csv") saves it.

Examples

Basic

Columns and rows, with nothing turned on: a styled native table.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; recipient: string; subject: string; mailer: string }

const DELIVERIES: Delivery[] = [
  { id: "d-1042", recipient: "ana@example.com", subject: "Your October invoice", mailer: "Amazon SES" },
  { id: "d-1041", recipient: "li.wei@example.com", subject: "Password reset", mailer: "Postmark" },
  { id: "d-1040", recipient: "sam@example.com", subject: "Welcome aboard", mailer: "Amazon SES" },
  { id: "d-1039", recipient: "kim@example.com", subject: "Weekly digest", mailer: "SMTP relay" },
  { id: "d-1038", recipient: "noor@example.com", subject: "Your receipt", mailer: "Postmark" },
]

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "id", header: "Code" },
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "mailer", header: "Mailer" },
]

export default function DataTableBasic() {
  return <DataTable aria-label="Recent deliveries" columns={COLUMNS} data={DELIVERIES} getRowId={(row) => row.id} />
}

Sizes

size sets the density: sm 26 px rows, default 38 px, lg 46 px.

import { useState } from "react"
import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"
import { SegmentedControl, SegmentedControlItem } from "@booleanpress/ui/segmented-control"

type Ticket = { id: string; subject: string; requester: string; priority: string }

const TICKETS: Ticket[] = [
  { id: "t-2081", subject: "Cannot connect to SES", requester: "Ana Ruiz", priority: "High" },
  { id: "t-2080", subject: "Invoice is missing a VAT number", requester: "Li Wei", priority: "Normal" },
  { id: "t-2079", subject: "Import stops at row 200", requester: "Sam Okoye", priority: "High" },
  { id: "t-2078", subject: "Change the sender name", requester: "Kim Park", priority: "Low" },
  { id: "t-2077", subject: "Webhook retries twice", requester: "Noor Haddad", priority: "Normal" },
]

const COLUMNS: DataTableColumnDef<Ticket>[] = [
  { accessorKey: "id", header: "Ticket" },
  { accessorKey: "subject", header: "Subject" },
  { accessorKey: "requester", header: "Requester" },
  { accessorKey: "priority", header: "Priority" },
]

export default function DataTableSizes() {
  const [size, setSize] = useState<"sm" | "default" | "lg">("default")

  return (
    <div className="flex w-full flex-col gap-4">
      <SegmentedControl value={size} onValueChange={(value) => setSize(value as typeof size)} aria-label="Table size" className="self-start">
        <SegmentedControlItem value="sm">Small</SegmentedControlItem>
        <SegmentedControlItem value="default">Normal</SegmentedControlItem>
        <SegmentedControlItem value="lg">Large</SegmentedControlItem>
      </SegmentedControl>
      <DataTable aria-label="Open tickets" size={size} columns={COLUMNS} data={TICKETS} getRowId={(row) => row.id} />
    </div>
  )
}

Gridlines

gridlines draws a line around every cell.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Mailer = { name: string; provider: string; sent: number; failed: number }

const MAILERS: Mailer[] = [
  { name: "Transactional", provider: "Amazon SES", sent: 18402, failed: 12 },
  { name: "Marketing", provider: "Postmark", sent: 9610, failed: 31 },
  { name: "Password resets", provider: "Postmark", sent: 2150, failed: 0 },
  { name: "Internal alerts", provider: "SMTP relay", sent: 415, failed: 9 },
  { name: "Receipts", provider: "Amazon SES", sent: 7044, failed: 4 },
]

const COLUMNS: DataTableColumnDef<Mailer>[] = [
  { accessorKey: "name", header: "Mailer" },
  { accessorKey: "provider", header: "Provider" },
  { accessorKey: "sent", header: "Sent", meta: { align: "end" }, cell: ({ getValue }) => getValue<number>().toLocaleString("en-GB") },
  { accessorKey: "failed", header: "Failed", meta: { align: "end" } },
]

export default function DataTableGridlines() {
  return <DataTable aria-label="Mailers, last 30 days" gridlines columns={COLUMNS} data={MAILERS} />
}

Striped

striped fills every other row with the faintest surface.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Customer = { name: string; organisation: string; plan: string; seats: number }

const CUSTOMERS: Customer[] = [
  { name: "Ana Ruiz", organisation: "Northwind Books", plan: "Agency", seats: 25 },
  { name: "Li Wei", organisation: "Blue Harbour", plan: "Pro", seats: 5 },
  { name: "Sam Okoye", organisation: "Acme Tools", plan: "Pro", seats: 8 },
  { name: "Kim Park", organisation: "Lumen Studio", plan: "Starter", seats: 1 },
  { name: "Noor Haddad", organisation: "Cedar Clinic", plan: "Agency", seats: 40 },
  { name: "Tom Becker", organisation: "Fjord Travel", plan: "Starter", seats: 2 },
  { name: "Ines Costa", organisation: "Porto Labs", plan: "Pro", seats: 12 },
]

const COLUMNS: DataTableColumnDef<Customer>[] = [
  { accessorKey: "name", header: "Customer" },
  { accessorKey: "organisation", header: "Organisation" },
  { accessorKey: "plan", header: "Plan" },
  { accessorKey: "seats", header: "Seats", meta: { align: "end" } },
]

export default function DataTableStriped() {
  return <DataTable aria-label="Customers" striped columns={COLUMNS} data={CUSTOMERS} />
}

Single selection

selection="single" adds a radio to each row; a press on the row chooses it too, and the arrow keys move between radios.

import { useState } from "react"
import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Mailer = { id: string; name: string; provider: string; region: string }

const MAILERS: Mailer[] = [
  { id: "ses-eu", name: "Transactional", provider: "Amazon SES", region: "eu-west-1" },
  { id: "pm-main", name: "Marketing", provider: "Postmark", region: "us-east-1" },
  { id: "smtp-int", name: "Internal alerts", provider: "SMTP relay", region: "On premises" },
  { id: "ses-us", name: "Receipts", provider: "Amazon SES", region: "us-east-2" },
]

const COLUMNS: DataTableColumnDef<Mailer>[] = [
  { accessorKey: "name", header: "Mailer" },
  { accessorKey: "provider", header: "Provider" },
  { accessorKey: "region", header: "Region" },
]

export default function DataTableSingleSelection() {
  const [rowSelection, setRowSelection] = useState<Record<string, true>>({ "ses-eu": true })
  const chosen = MAILERS.find((mailer) => rowSelection[mailer.id])

  return (
    <div className="flex w-full flex-col gap-3">
      <DataTable
        aria-label="Default mailer"
        selection="single"
        columns={COLUMNS}
        data={MAILERS}
        getRowId={(row) => row.id}
        state={{ rowSelection }}
        onRowSelectionChange={setRowSelection}
      />
      <p className="text-sm text-muted-foreground">Default mailer: {chosen ? chosen.name : "none"}</p>
    </div>
  )
}

Multiple selection

selection="multiple" adds checkboxes and a select-all box that shows a dash while some rows are chosen. Shift chooses a range.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"
import { Badge } from "@booleanpress/ui/badge"

type Ticket = { id: string; subject: string; requester: string; status: "Open" | "Pending" | "Solved" }

const TICKETS: Ticket[] = [
  { id: "t-2081", subject: "Cannot connect to SES", requester: "Ana Ruiz", status: "Open" },
  { id: "t-2080", subject: "Invoice is missing a VAT number", requester: "Li Wei", status: "Pending" },
  { id: "t-2079", subject: "Import stops at row 200", requester: "Sam Okoye", status: "Open" },
  { id: "t-2078", subject: "Change the sender name", requester: "Kim Park", status: "Solved" },
  { id: "t-2077", subject: "Webhook retries twice", requester: "Noor Haddad", status: "Pending" },
  { id: "t-2076", subject: "Export to CSV is empty", requester: "Tom Becker", status: "Open" },
]

const STATUS = { Open: "info", Pending: "warning", Solved: "success" } as const

const COLUMNS: DataTableColumnDef<Ticket>[] = [
  { accessorKey: "id", header: "Ticket" },
  { accessorKey: "subject", header: "Subject" },
  { accessorKey: "requester", header: "Requester" },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => <Badge variant={STATUS[row.original.status]}>{row.original.status}</Badge>,
  },
]

export default function DataTableMultipleSelection() {
  return (
    <DataTable
      aria-label="Tickets"
      selection="multiple"
      toolbar={<h3 className="text-sm font-semibold">Tickets</h3>}
      columns={COLUMNS}
      data={TICKETS}
      getRowId={(row) => row.id}
      initialState={{ rowSelection: { "t-2080": true } }}
    />
  )
}

Sort

sorting makes each title a sort button. Shift and a press add a second column to the sort.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; recipient: string; mailer: string; opens: number; sent: string }

const DELIVERIES: Delivery[] = [
  { id: "d-1042", recipient: "ana@example.com", mailer: "Amazon SES", opens: 3, sent: "2026-10-04 10:42" },
  { id: "d-1041", recipient: "li.wei@example.com", mailer: "Postmark", opens: 0, sent: "2026-10-04 10:40" },
  { id: "d-1040", recipient: "sam@example.com", mailer: "Amazon SES", opens: 7, sent: "2026-10-04 09:58" },
  { id: "d-1039", recipient: "kim@example.com", mailer: "SMTP relay", opens: 1, sent: "2026-10-03 17:15" },
  { id: "d-1038", recipient: "noor@example.com", mailer: "Postmark", opens: 12, sent: "2026-10-03 16:02" },
  { id: "d-1037", recipient: "tom@example.com", mailer: "Amazon SES", opens: 2, sent: "2026-10-03 08:30" },
]

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "mailer", header: "Mailer" },
  { accessorKey: "opens", header: "Opens", meta: { align: "end" } },
  { accessorKey: "sent", header: "Sent", sortFn: "alphanumeric" },
]

export default function DataTableSort() {
  return (
    <DataTable
      aria-label="Deliveries"
      sorting
      columns={COLUMNS}
      data={DELIVERIES}
      getRowId={(row) => row.id}
      initialState={{ sorting: [{ id: "sent", desc: true }] }}
    />
  )
}

Pagination

pagination splits the rows into pages of 10, with the paginator under the table: First, Previous, the pages, Next and Last.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"
import { Badge } from "@booleanpress/ui/badge"

type Delivery = { id: string; recipient: string; subject: string; status: "Delivered" | "Bounced" | "Deferred" }

const SUBJECTS = ["Your October invoice", "Password reset", "Welcome aboard", "Weekly digest", "Your receipt"]
const STATUSES = ["Delivered", "Delivered", "Bounced", "Delivered", "Deferred"] as const

// 57 deliveries, built from fixed lists so every visit shows the same rows.
const DELIVERIES: Delivery[] = Array.from({ length: 57 }, (_, index) => ({
  id: `d-${1100 - index}`,
  recipient: `customer${String(index + 1).padStart(2, "0")}@example.com`,
  subject: SUBJECTS[index % SUBJECTS.length],
  status: STATUSES[index % STATUSES.length],
}))

const TONE = { Delivered: "success", Bounced: "destructive", Deferred: "warning" } as const

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "id", header: "Delivery" },
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "subject", header: "Subject" },
  { accessorKey: "status", header: "Status", cell: ({ row }) => <Badge variant={TONE[row.original.status]}>{row.original.status}</Badge> },
]

export default function DataTablePagination() {
  return <DataTable aria-label="Deliveries" pagination columns={COLUMNS} data={DELIVERIES} getRowId={(row) => row.id} />
}

Scroll

stickyHeader with maxHeight keeps the header in view; a pinned first column stays put while the table scrolls sideways.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Customer = { id: string; name: string; organisation: string; plan: string; country: string; seats: number; mrr: number; renews: string }

const NAMES = ["Ana Ruiz", "Li Wei", "Sam Okoye", "Kim Park", "Noor Haddad", "Tom Becker", "Ines Costa", "Omar Aziz", "Eva Novak", "Raj Mehta"]
const ORGS = ["Northwind Books", "Blue Harbour", "Acme Tools", "Lumen Studio", "Cedar Clinic", "Fjord Travel", "Porto Labs", "Atlas Freight", "Vltava Press", "Saffron Foods"]
const COUNTRIES = ["Spain", "Singapore", "Nigeria", "South Korea", "Jordan", "Norway", "Portugal", "Egypt", "Czechia", "India"]

const CUSTOMERS: Customer[] = Array.from({ length: 20 }, (_, index) => ({
  id: `c-${300 + index}`,
  name: NAMES[index % 10],
  organisation: ORGS[(index * 3) % 10],
  plan: ["Starter", "Pro", "Agency"][index % 3],
  country: COUNTRIES[(index * 7) % 10],
  seats: 1 + ((index * 13) % 40),
  mrr: 19 + ((index * 37) % 400),
  renews: `2026-${String(1 + (index % 12)).padStart(2, "0")}-15`,
}))

const COLUMNS: DataTableColumnDef<Customer>[] = [
  { accessorKey: "name", header: "Customer", size: 160, meta: { cellClassName: "font-semibold" } },
  { accessorKey: "id", header: "Id", meta: { cellClassName: "min-w-24" } },
  { accessorKey: "organisation", header: "Organisation", meta: { cellClassName: "min-w-44" } },
  { accessorKey: "plan", header: "Plan", meta: { cellClassName: "min-w-28" } },
  { accessorKey: "country", header: "Country", meta: { cellClassName: "min-w-36" } },
  { accessorKey: "seats", header: "Seats", meta: { align: "end", cellClassName: "min-w-24" } },
  { accessorKey: "mrr", header: "MRR", meta: { align: "end", cellClassName: "min-w-28" }, cell: ({ getValue }) => `$${getValue<number>()}` },
  { accessorKey: "renews", header: "Renews", meta: { cellClassName: "min-w-32" } },
]

export default function DataTableScroll() {
  return (
    <div className="w-full max-w-2xl">
      <DataTable
        aria-label="Customers"
        stickyHeader
        maxHeight={360}
        columns={COLUMNS}
        data={CUSTOMERS}
        getRowId={(row) => row.id}
        initialState={{ columnPinning: { start: ["name"], end: [] } }}
      />
    </div>
  )
}

Row expansion

renderSubRow adds an expand button; an expanded row is followed by its details.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"
import { Badge } from "@booleanpress/ui/badge"

type Event = { time: string; event: string }
type Delivery = { id: string; recipient: string; subject: string; status: "Delivered" | "Bounced"; events: Event[] }

const DELIVERIES: Delivery[] = [
  { id: "d-1042", recipient: "ana@example.com", subject: "Your October invoice", status: "Delivered", events: [{ time: "10:42:01", event: "Accepted by Amazon SES" }, { time: "10:42:03", event: "Delivered to mx.example.com" }, { time: "11:05:40", event: "Opened" }] },
  { id: "d-1041", recipient: "li.wei@example.com", subject: "Password reset", status: "Bounced", events: [{ time: "10:40:12", event: "Accepted by Postmark" }, { time: "10:40:15", event: "Bounced: mailbox full (552)" }] },
  { id: "d-1040", recipient: "sam@example.com", subject: "Welcome aboard", status: "Delivered", events: [{ time: "09:58:30", event: "Accepted by Amazon SES" }, { time: "09:58:31", event: "Delivered to mx.example.com" }] },
  { id: "d-1039", recipient: "kim@example.com", subject: "Weekly digest", status: "Delivered", events: [{ time: "17:15:00", event: "Accepted by SMTP relay" }, { time: "17:15:09", event: "Delivered to mail.example.com" }] },
]

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "subject", header: "Subject" },
  { accessorKey: "status", header: "Status", cell: ({ row }) => <Badge variant={row.original.status === "Bounced" ? "destructive" : "success"}>{row.original.status}</Badge> },
]

export default function DataTableRowExpansion() {
  return (
    <DataTable
      aria-label="Deliveries"
      columns={COLUMNS}
      data={DELIVERIES}
      getRowId={(row) => row.id}
      initialState={{ expanded: { "d-1042": true } }}
      renderSubRow={(row) => (
        <div className="rounded-md bg-subtle p-4">
          <p className="mb-2 font-semibold">Events for {row.original.id}</p>
          <ol className="flex flex-col gap-1 text-muted-foreground">
            {row.original.events.map((item) => (
              <li key={item.time} className="flex gap-4">
                <span className="tabular-nums">{item.time}</span>
                <span className="text-foreground">{item.event}</span>
              </li>
            ))}
          </ol>
        </div>
      )}
    />
  )
}

Cell editing

Columns with meta.editor edit in place: Enter or F2 starts, Enter commits, Escape cancels. onCellEdit receives the new value.

import { useState } from "react"
import { DataTable, type DataTableCellEdit, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Mailer = { id: string; name: string; provider: string; dailyLimit: number }

const PROVIDERS = ["Amazon SES", "Postmark", "SMTP relay"].map((value) => ({ label: value, value }))

const COLUMNS: DataTableColumnDef<Mailer>[] = [
  { accessorKey: "name", header: "Mailer", meta: { editor: "text" } },
  { accessorKey: "provider", header: "Provider", meta: { editor: "select", editorOptions: PROVIDERS } },
  {
    accessorKey: "dailyLimit",
    header: "Daily limit",
    meta: { editor: "number", align: "end" },
    cell: ({ getValue }) => getValue<number>().toLocaleString("en-GB"),
  },
]

export default function DataTableCellEditing() {
  const [mailers, setMailers] = useState<Mailer[]>([
    { id: "m-1", name: "Transactional", provider: "Amazon SES", dailyLimit: 50000 },
    { id: "m-2", name: "Marketing", provider: "Postmark", dailyLimit: 20000 },
    { id: "m-3", name: "Password resets", provider: "Postmark", dailyLimit: 5000 },
    { id: "m-4", name: "Internal alerts", provider: "SMTP relay", dailyLimit: 1000 },
  ])

  const onCellEdit = ({ rowId, columnId, value }: DataTableCellEdit<Mailer>) =>
    setMailers((rows) => rows.map((row) => (row.id === rowId ? { ...row, [columnId]: value ?? 0 } : row)))

  return <DataTable aria-label="Mailers" columns={COLUMNS} data={mailers} getRowId={(row) => row.id} onCellEdit={onCellEdit} />
}

Row grouping

grouping with grouping: ["mailer"] puts the rows under a header row per mailer, with the column's aggregationFn total under each group.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; mailer: string; recipient: string; subject: string; sent: number }

const DELIVERIES: Delivery[] = [
  { id: "1", mailer: "Amazon SES", recipient: "ana@example.com", subject: "Your October invoice", sent: 1840 },
  { id: "2", mailer: "Postmark", recipient: "li.wei@example.com", subject: "Password reset", sent: 960 },
  { id: "3", mailer: "Amazon SES", recipient: "sam@example.com", subject: "Welcome aboard", sent: 312 },
  { id: "4", mailer: "SMTP relay", recipient: "kim@example.com", subject: "Weekly digest", sent: 215 },
  { id: "5", mailer: "Postmark", recipient: "noor@example.com", subject: "Your receipt", sent: 488 },
  { id: "6", mailer: "Amazon SES", recipient: "tom@example.com", subject: "Trial ends soon", sent: 97 },
]

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "mailer", header: "Mailer" },
  {
    accessorKey: "recipient",
    header: "Recipient",
    aggregationFn: "count",
    aggregatedCell: () => <span className="font-normal text-muted-foreground">Group total</span>,
  },
  { accessorKey: "subject", header: "Subject" },
  {
    accessorKey: "sent",
    header: "Sent",
    aggregationFn: "sum",
    meta: { align: "end" },
    cell: ({ getValue }) => getValue<number>().toLocaleString("en-GB"),
    aggregatedCell: ({ getValue }) => getValue<number>().toLocaleString("en-GB"),
  },
]

export default function DataTableRowGrouping() {
  return (
    <DataTable
      aria-label="Deliveries by mailer"
      grouping
      columns={COLUMNS}
      data={DELIVERIES}
      getRowId={(row) => row.id}
      initialState={{ grouping: ["mailer"] }}
    />
  )
}

Column resize

columnResizing lets each column's edge be dragged, or moved with the arrow keys on its focused handle.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Ticket = { id: string; subject: string; requester: string; priority: string }

const TICKETS: Ticket[] = [
  { id: "t-2081", subject: "Cannot connect to SES after rotating the access keys", requester: "Ana Ruiz", priority: "High" },
  { id: "t-2080", subject: "Invoice is missing a VAT number", requester: "Li Wei", priority: "Normal" },
  { id: "t-2079", subject: "Import stops at row 200 with no error", requester: "Sam Okoye", priority: "High" },
  { id: "t-2078", subject: "Change the sender name on receipts", requester: "Kim Park", priority: "Low" },
  { id: "t-2077", subject: "Webhook retries twice", requester: "Noor Haddad", priority: "Normal" },
]

const COLUMNS: DataTableColumnDef<Ticket>[] = [
  { accessorKey: "id", header: "Ticket", size: 110, minSize: 80 },
  { accessorKey: "subject", header: "Subject", size: 260, minSize: 120 },
  { accessorKey: "requester", header: "Requester", size: 160, minSize: 100 },
  { accessorKey: "priority", header: "Priority", size: 120, minSize: 90 },
]

export default function DataTableColumnResize() {
  return <DataTable aria-label="Tickets" columnResizing gridlines columns={COLUMNS} data={TICKETS} getRowId={(row) => row.id} />
}

Column reorder

columnOrdering lets a column be dragged by its header, or moved with Move left and Move right in its menu.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; recipient: string; mailer: string; opens: number; sent: string }

const DELIVERIES: Delivery[] = [
  { id: "d-1042", recipient: "ana@example.com", mailer: "Amazon SES", opens: 3, sent: "10:42" },
  { id: "d-1041", recipient: "li.wei@example.com", mailer: "Postmark", opens: 0, sent: "10:40" },
  { id: "d-1040", recipient: "sam@example.com", mailer: "Amazon SES", opens: 7, sent: "09:58" },
  { id: "d-1039", recipient: "kim@example.com", mailer: "SMTP relay", opens: 1, sent: "09:15" },
  { id: "d-1038", recipient: "noor@example.com", mailer: "Postmark", opens: 12, sent: "08:02" },
]

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "mailer", header: "Mailer" },
  { accessorKey: "opens", header: "Opens" },
  { accessorKey: "sent", header: "Sent" },
]

export default function DataTableColumnReorder() {
  return <DataTable aria-label="Deliveries" columnOrdering columns={COLUMNS} data={DELIVERIES} getRowId={(row) => row.id} />
}

Row reorder

ReorderableDataTable from @booleanpress/ui/data-table-reorder adds a drag handle to each row: drag it, or press Space, move with the arrow keys and press Space again. onRowOrderChange receives the new order.

import { useState } from "react"
import type { DataTableColumnDef } from "@booleanpress/ui/data-table"
import { ReorderableDataTable } from "@booleanpress/ui/data-table-reorder"

type Connection = { id: string; name: string; provider: string; region: string; dailyLimit: number }

const CONNECTIONS: Connection[] = [
  { id: "c-1", name: "Transactional", provider: "Amazon SES", region: "eu-west-1", dailyLimit: 50000 },
  { id: "c-2", name: "Receipts", provider: "Postmark", region: "us-east-1", dailyLimit: 20000 },
  { id: "c-3", name: "Newsletters", provider: "Mailgun", region: "eu-central-1", dailyLimit: 100000 },
  { id: "c-4", name: "Fallback relay", provider: "SMTP relay", region: "On premises", dailyLimit: 5000 },
  { id: "c-5", name: "Alerts", provider: "SendGrid", region: "us-west-2", dailyLimit: 10000 },
]

const number = new Intl.NumberFormat("en-US")

const COLUMNS: DataTableColumnDef<Connection>[] = [
  { accessorKey: "name", header: "Connection" },
  { accessorKey: "provider", header: "Provider" },
  { accessorKey: "region", header: "Region" },
  {
    accessorKey: "dailyLimit",
    header: "Daily limit",
    meta: { align: "end" },
    cell: ({ row }) => number.format(row.original.dailyLimit),
  },
]

export default function DataTableRowReorder() {
  const [connections, setConnections] = useState(CONNECTIONS)

  return (
    <ReorderableDataTable
      aria-label="Connections in the order they are tried"
      columns={COLUMNS}
      data={connections}
      getRowId={(row) => row.id}
      getRowLabel={(row) => row.name}
      onRowOrderChange={setConnections}
    />
  )
}

Column visibility

columnVisibility adds the Columns menu above the table and a Hide item to each column's menu. The last column shown cannot be hidden.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type ApiKey = { id: string; name: string; prefix: string; scope: string; created: string; lastUsed: string }

const KEYS: ApiKey[] = [
  { id: "k-1", name: "Production", prefix: "bp_live_7f3a", scope: "Send", created: "2026-03-12", lastUsed: "2026-10-04" },
  { id: "k-2", name: "Staging", prefix: "bp_test_19c2", scope: "Send, read logs", created: "2026-05-02", lastUsed: "2026-10-01" },
  { id: "k-3", name: "Zapier", prefix: "bp_live_c08e", scope: "Read logs", created: "2026-07-21", lastUsed: "2026-09-28" },
  { id: "k-4", name: "Reporting script", prefix: "bp_live_aa51", scope: "Read logs", created: "2026-08-30", lastUsed: "2026-10-03" },
]

const COLUMNS: DataTableColumnDef<ApiKey>[] = [
  { accessorKey: "name", header: "Name", enableHiding: false },
  { accessorKey: "prefix", header: "Key", meta: { cellClassName: "font-mono text-xs" } },
  { accessorKey: "scope", header: "Scope" },
  { accessorKey: "created", header: "Created" },
  { accessorKey: "lastUsed", header: "Last used" },
]

export default function DataTableColumnVisibility() {
  return (
    <DataTable
      aria-label="API keys"
      columnVisibility
      columns={COLUMNS}
      data={KEYS}
      getRowId={(row) => row.id}
      initialState={{ columnVisibility: { created: false } }}
    />
  )
}

Column groups

Columns with columns of their own make a second header row; footer adds the totals row.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Mailer = { name: string; sentLast: number; sentThis: number; bouncedLast: number; bouncedThis: number }

const MAILERS: Mailer[] = [
  { name: "Transactional", sentLast: 16210, sentThis: 18402, bouncedLast: 0.8, bouncedThis: 0.6 },
  { name: "Marketing", sentLast: 11050, sentThis: 9610, bouncedLast: 2.1, bouncedThis: 2.9 },
  { name: "Password resets", sentLast: 1980, sentThis: 2150, bouncedLast: 0.2, bouncedThis: 0.1 },
  { name: "Receipts", sentLast: 6830, sentThis: 7044, bouncedLast: 0.4, bouncedThis: 0.5 },
]

const total = (key: "sentLast" | "sentThis") => MAILERS.reduce((sum, row) => sum + row[key], 0).toLocaleString("en-GB")
const count = (value: number) => value.toLocaleString("en-GB")
const rate = (value: number) => `${value.toFixed(1)}%`

const COLUMNS: DataTableColumnDef<Mailer>[] = [
  { accessorKey: "name", header: "Mailer", footer: "Totals" },
  {
    id: "sent",
    header: "Sent",
    columns: [
      { accessorKey: "sentLast", header: "September", cell: ({ getValue }) => count(getValue<number>()), footer: () => total("sentLast") },
      { accessorKey: "sentThis", header: "October", cell: ({ getValue }) => count(getValue<number>()), footer: () => total("sentThis") },
    ],
  },
  {
    id: "bounced",
    header: "Bounce rate",
    columns: [
      { accessorKey: "bouncedLast", header: "September", cell: ({ getValue }) => rate(getValue<number>()) },
      { accessorKey: "bouncedThis", header: "October", cell: ({ getValue }) => rate(getValue<number>()) },
    ],
  },
]

export default function DataTableColumnGroups() {
  return <DataTable aria-label="Mailers by month" gridlines columns={COLUMNS} data={MAILERS} />
}

Filter

filtering adds a search field over every column and a filter row: text fields, and a select where meta.filter gives options.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"
import { Badge } from "@booleanpress/ui/badge"

type Ticket = { id: string; subject: string; requester: string; status: "Open" | "Pending" | "Solved" }

const TICKETS: Ticket[] = [
  { id: "t-2081", subject: "Cannot connect to SES", requester: "Ana Ruiz", status: "Open" },
  { id: "t-2080", subject: "Invoice is missing a VAT number", requester: "Li Wei", status: "Pending" },
  { id: "t-2079", subject: "Import stops at row 200", requester: "Sam Okoye", status: "Open" },
  { id: "t-2078", subject: "Change the sender name", requester: "Kim Park", status: "Solved" },
  { id: "t-2077", subject: "Webhook retries twice", requester: "Noor Haddad", status: "Pending" },
  { id: "t-2076", subject: "Export to CSV is empty", requester: "Tom Becker", status: "Open" },
  { id: "t-2075", subject: "Add a second admin", requester: "Ines Costa", status: "Solved" },
]

const TONE = { Open: "info", Pending: "warning", Solved: "success" } as const
const STATUSES = ["Open", "Pending", "Solved"].map((value) => ({ label: value, value }))

const COLUMNS: DataTableColumnDef<Ticket>[] = [
  { accessorKey: "id", header: "Ticket", meta: { filter: { placeholder: "Search" } } },
  { accessorKey: "subject", header: "Subject", meta: { filter: { placeholder: "Search" } } },
  { accessorKey: "requester", header: "Requester", meta: { filter: { placeholder: "Search" } } },
  {
    accessorKey: "status",
    header: "Status",
    meta: { filter: { variant: "select", placeholder: "Any", options: STATUSES } },
    cell: ({ row }) => <Badge variant={TONE[row.original.status]}>{row.original.status}</Badge>,
  },
]

export default function DataTableFilter() {
  return <DataTable aria-label="Tickets" filtering columns={COLUMNS} data={TICKETS} getRowId={(row) => row.id} />
}

Export CSV

exportCsv adds a button that saves the visible columns, and the filtered rows in the order shown, as a CSV file.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; recipient: string; subject: string; mailer: string; opens: number }

const DELIVERIES: Delivery[] = [
  { id: "d-1042", recipient: "ana@example.com", subject: "Your October invoice", mailer: "Amazon SES", opens: 3 },
  { id: "d-1041", recipient: "li.wei@example.com", subject: "Password reset", mailer: "Postmark", opens: 0 },
  { id: "d-1040", recipient: "sam@example.com", subject: "Welcome aboard", mailer: "Amazon SES", opens: 7 },
  { id: "d-1039", recipient: "kim@example.com", subject: "Weekly digest, October", mailer: "SMTP relay", opens: 1 },
  { id: "d-1038", recipient: "noor@example.com", subject: "Your receipt", mailer: "Postmark", opens: 12 },
]

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "id", header: "Delivery" },
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "subject", header: "Subject" },
  { accessorKey: "mailer", header: "Mailer" },
  { accessorKey: "opens", header: "Opens", meta: { align: "end" } },
]

export default function DataTableExportCsv() {
  return (
    <DataTable
      aria-label="Deliveries"
      sorting
      exportCsv="deliveries.csv"
      toolbar={<p className="text-sm text-muted-foreground">Saves the visible columns and rows, in the order shown.</p>}
      columns={COLUMNS}
      data={DELIVERIES}
      getRowId={(row) => row.id}
    />
  )
}

Server mode

manualSorting, manualFiltering and manualPagination with controlled state: the first page comes with the page, and a fake server answers each change after 600 ms.

import { useEffect, useState } from "react"
import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; recipient: string; mailer: string; opens: number }
type Query = { sorting: { id: string; desc: boolean }[]; pagination: { pageIndex: number; pageSize: number }; globalFilter: string }

// The "server": 240 deliveries it sorts, filters and pages, answering after a fixed 600 ms.
const ALL: Delivery[] = Array.from({ length: 240 }, (_, index) => ({
  id: `d-${2000 + index}`,
  recipient: `customer${index + 1}@example.com`,
  mailer: ["Amazon SES", "Postmark", "SMTP relay"][index % 3],
  opens: (index * 7) % 23,
}))

function fetchPage({ sorting, pagination, globalFilter }: Query) {
  const term = globalFilter.toLowerCase()
  const rows = ALL.filter((row) => !term || `${row.id} ${row.recipient} ${row.mailer}`.toLowerCase().includes(term))
  const [sort] = sorting
  if (sort) rows.sort((a, b) => String(a[sort.id as keyof Delivery]).localeCompare(String(b[sort.id as keyof Delivery]), "en", { numeric: true }) * (sort.desc ? -1 : 1))
  const start = pagination.pageIndex * pagination.pageSize
  return { rows: rows.slice(start, start + pagination.pageSize), total: rows.length }
}

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "id", header: "Delivery" },
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "mailer", header: "Mailer" },
  { accessorKey: "opens", header: "Opens", meta: { align: "end" } },
]

// The first page comes with the page, as a server-rendered page would bring it; every change after that asks the server.
const FIRST: Query = { sorting: [], pagination: { pageIndex: 0, pageSize: 10 }, globalFilter: "" }

export default function DataTableServerMode() {
  const [query, setQuery] = useState(FIRST)
  const [result, setResult] = useState(() => ({ query: FIRST, ...fetchPage(FIRST) }))

  useEffect(() => {
    if (query === FIRST) return
    // A newer query cancels the answer to an older one, so a slow answer never replaces a newer page.
    const timer = setTimeout(() => setResult({ query, ...fetchPage(query) }), 600)
    return () => clearTimeout(timer)
  }, [query])

  return (
    <DataTable
      aria-label="Deliveries"
      sorting
      filtering="global"
      pagination
      manualSorting
      manualFiltering
      manualPagination
      loading={result.query !== query}
      columns={COLUMNS}
      data={result.rows}
      rowCount={result.total}
      getRowId={(row) => row.id}
      state={query}
      onSortingChange={(next) => setQuery((old) => ({ ...old, sorting: typeof next === "function" ? next(old.sorting) : next }))}
      onPaginationChange={(next) => setQuery((old) => ({ ...old, pagination: typeof next === "function" ? next(old.pagination) : next }))}
      onGlobalFilterChange={(next) =>
        setQuery((old) => ({ ...old, globalFilter: typeof next === "function" ? next(old.globalFilter) : next, pagination: { ...old.pagination, pageIndex: 0 } }))
      }
    />
  )
}

Loading

loading draws a spinner over the rows while they refresh (press Refresh), and skeleton rows before the first rows arrive.

import { useEffect, useState } from "react"
import { RefreshCwIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: string; recipient: string; subject: string; mailer: string }

const DELIVERIES: Delivery[] = [
  { id: "d-1042", recipient: "ana@example.com", subject: "Your October invoice", mailer: "Amazon SES" },
  { id: "d-1041", recipient: "li.wei@example.com", subject: "Password reset", mailer: "Postmark" },
  { id: "d-1040", recipient: "sam@example.com", subject: "Welcome aboard", mailer: "Amazon SES" },
  { id: "d-1039", recipient: "kim@example.com", subject: "Weekly digest", mailer: "SMTP relay" },
]

const NONE: Delivery[] = []

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "recipient", header: "Recipient" },
  { accessorKey: "subject", header: "Subject" },
  { accessorKey: "mailer", header: "Mailer" },
]

export default function DataTableLoading() {
  const [request, setRequest] = useState(0)
  const [answered, setAnswered] = useState(0)
  const refreshing = answered !== request

  // A fake refresh that answers after 1.5 s: a spinner covers the rows meanwhile.
  useEffect(() => {
    if (!refreshing) return
    const timer = setTimeout(() => setAnswered(request), 1500)
    return () => clearTimeout(timer)
  }, [refreshing, request])

  return (
    <div className="flex w-full flex-col gap-6">
      <DataTable
        aria-label="Deliveries"
        loading={refreshing}
        toolbar={
          <Button variant="outline" size="sm" onClick={() => setRequest((count) => count + 1)} disabled={refreshing} className="ms-auto">
            <RefreshCwIcon />
            Refresh
          </Button>
        }
        columns={COLUMNS}
        data={DELIVERIES}
        getRowId={(row) => row.id}
      />
      {/* Before the first rows arrive: skeleton rows. */}
      <DataTable aria-label="Deliveries, first load" loading columns={COLUMNS} data={NONE} />
    </div>
  )
}

Empty

empty replaces the table's body when there are no rows.

import { InboxIcon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@booleanpress/ui/empty"

type Mailer = { id: string; name: string; provider: string; status: string }

const COLUMNS: DataTableColumnDef<Mailer>[] = [
  { accessorKey: "name", header: "Mailer" },
  { accessorKey: "provider", header: "Provider" },
  { accessorKey: "status", header: "Status" },
]

const NONE: Mailer[] = []

export default function DataTableEmpty() {
  return (
    <DataTable
      aria-label="Mailers"
      columns={COLUMNS}
      data={NONE}
      empty={
        <Empty className="p-6 md:p-10">
          <EmptyHeader>
            <EmptyMedia variant="icon" className="size-14 rounded-full bg-muted text-control-hover">
              <InboxIcon className="size-7" />
            </EmptyMedia>
            <EmptyTitle className="text-base font-semibold">No mailers yet</EmptyTitle>
            <EmptyDescription>Connect a mailer to start sending email.</EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Button size="sm">
              <PlusIcon />
              Add mailer
            </Button>
          </EmptyContent>
        </Empty>
      }
    />
  )
}

Virtual rows

virtual with maxHeight draws only the rows in view: 10,000 deliveries scroll smoothly.

import { DataTable, type DataTableColumnDef } from "@booleanpress/ui/data-table"

type Delivery = { id: number; recipient: string; mailer: string; status: string; opens: number }

const MAILERS = ["Amazon SES", "Postmark", "SMTP relay"]
const STATUSES = ["Delivered", "Delivered", "Delivered", "Bounced", "Deferred"]

// 10,000 deliveries, computed from their index so every visit shows the same rows.
const DELIVERIES: Delivery[] = Array.from({ length: 10000 }, (_, index) => ({
  id: 100000 + index,
  recipient: `customer${index + 1}@example.com`,
  mailer: MAILERS[index % 3],
  status: STATUSES[(index * 7) % 5],
  opens: (index * 13) % 31,
}))

const COLUMNS: DataTableColumnDef<Delivery>[] = [
  { accessorKey: "id", header: "Delivery", size: 120 },
  { accessorKey: "recipient", header: "Recipient", size: 260 },
  { accessorKey: "mailer", header: "Mailer", size: 160 },
  { accessorKey: "status", header: "Status", size: 140 },
  { accessorKey: "opens", header: "Opens", size: 100, meta: { align: "end" } },
]

export default function DataTableVirtualRows() {
  return (
    <DataTable
      aria-label="All deliveries"
      virtual
      stickyHeader
      sorting
      maxHeight={420}
      columns={COLUMNS}
      data={DELIVERIES}
      getRowId={(row) => String(row.id)}
    />
  )
}

Advanced

The toolbar, search, filter row, sorting, selection, row actions, column visibility, resizing and pagination together.

import { useState } from "react"
import { MailIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { Button } from "@booleanpress/ui/button"
import { DataTable, DataTableRowActions, type DataTableColumnDef } from "@booleanpress/ui/data-table"
import { DropdownMenuItem } from "@booleanpress/ui/dropdown-menu"

type Ticket = { id: string; subject: string; requester: string; priority: "High" | "Normal" | "Low"; status: "Open" | "Pending" | "Solved"; updated: string }

const SUBJECTS = ["Cannot connect to SES", "Invoice is missing a VAT number", "Import stops at row 200", "Change the sender name", "Webhook retries twice", "Export to CSV is empty"]
const PEOPLE = ["Ana Ruiz", "Li Wei", "Sam Okoye", "Kim Park", "Noor Haddad", "Tom Becker", "Ines Costa"]
const TICKETS: Ticket[] = Array.from({ length: 36 }, (_, index) => ({
  id: `t-${2100 - index}`,
  subject: SUBJECTS[index % SUBJECTS.length],
  requester: PEOPLE[(index * 3) % PEOPLE.length],
  priority: (["High", "Normal", "Low"] as const)[(index * 5) % 3],
  status: (["Open", "Pending", "Solved"] as const)[index % 3],
  updated: `2026-10-${String(30 - (index % 28)).padStart(2, "0")}`,
}))

const TONE = { Open: "info", Pending: "warning", Solved: "success" } as const
const option = (value: string) => ({ label: value, value })

const COLUMNS: DataTableColumnDef<Ticket>[] = [
  { accessorKey: "id", header: "Ticket", size: 110 },
  { accessorKey: "subject", header: "Subject", size: 260 },
  { accessorKey: "requester", header: "Requester", size: 160 },
  { accessorKey: "priority", header: "Priority", size: 140, meta: { filter: { variant: "select", placeholder: "Any", options: ["High", "Normal", "Low"].map(option) } } },
  {
    accessorKey: "status",
    header: "Status",
    size: 140,
    meta: { filter: { variant: "select", placeholder: "Any", options: ["Open", "Pending", "Solved"].map(option) } },
    cell: ({ row }) => <Badge variant={TONE[row.original.status]}>{row.original.status}</Badge>,
  },
  { accessorKey: "updated", header: "Updated", size: 130, enableColumnFilter: false },
  {
    id: "actions",
    size: 64,
    cell: ({ row }) => (
      <DataTableRowActions label={row.original.id}>
        <DropdownMenuItem>Open ticket</DropdownMenuItem>
        <DropdownMenuItem>Assign to me</DropdownMenuItem>
        <DropdownMenuItem>Close</DropdownMenuItem>
      </DataTableRowActions>
    ),
  },
]

export default function DataTableAdvanced() {
  const [rowSelection, setRowSelection] = useState<Record<string, true>>({})
  const selected = Object.keys(rowSelection).length

  return (
    <DataTable
      aria-label="Support tickets"
      sorting
      filtering
      pagination={{ pageSizes: [10, 20, 50], range: true }}
      selection="multiple"
      columnVisibility
      columnResizing
      exportCsv="tickets.csv"
      toolbar={
        <Button size="sm" variant="secondary" disabled={!selected}>
          <MailIcon />
          Email requesters
        </Button>
      }
      columns={COLUMNS}
      data={TICKETS}
      getRowId={(row) => row.id}
      getRowLabel={(row) => row.id}
      state={{ rowSelection }}
      onRowSelectionChange={setRowSelection}
      initialState={{ sorting: [{ id: "updated", desc: true }] }}
    />
  )
}

Accessibility

Semantics
A native table with thead, tbody, th scope="col" and td, so a screen reader announces each cell with its column. A sortable column's title is a button, and its th carries aria-sort (ascending, descending or none; with several sort columns, only the first). A column without a title (selection, expansion, actions) has an empty td in the header row, not an empty header. Virtual rows keep aria-rowcount on the table and aria-rowindex on every row.
Labels
Name the table with aria-label, aria-labelledby or caption. Row checkboxes and radios are named "Select row {name}", the header box "Select all rows on this page", expand buttons "Expand row {name}" / "Collapse row {name}" with aria-expanded, resize handles "Resize {column}" (role="separator" with aria-valuenow in pixels), column menus "{column} options", filter fields "Filter {column}", editable cells "Edit {column}" followed by the value, row actions "Actions for {name}", and drag handles "Drag {name}" (the provider's dragHandle). A row's name is getRowLabel, else its first column's value. A polite live region announces the sort ("Sorted by Status, ascending"), the number of results after a filter ("12 results") and the range of a new page ("11–20 of 120"); while a row moves, it reads the pick-up with the keys to use, each new place and the drop or the cancel (dragStarted, itemMoved, dragCancelled), positions counted in data.
Focus
The table is not a tab stop; its controls are, in reading order: sort buttons, column menus, resize handles, the filter row, then each row's drag handle, checkbox or radio, expand button, editable cells and actions. After a keyboard move, focus stays on the moved row's handle. Single-selection radios share a name, so Tab enters the group once and the arrow keys move the selection. After an edit, focus returns to the cell. Escape in a cell's editor, or while a row is picked up, cancels only that: a dialog, sheet or popover round the table stays open. When a column is hidden from its own menu, focus moves to the first control left in the header row. A table wider than its box scrolls and becomes focusable when nothing inside it is.
Known limits
  • It is a table, not an ARIA grid: there is no arrow-key navigation between cells. Each control is a tab stop instead. A grid model needs a spec of its own (spec standard §6.1).
  • Selection is shown by the highlight and by the checkbox or radio; keep the selection column, as a press on the row alone is not keyboard accessible.
  • Dragging a column header uses the browser's drag and drop, which keyboards and many screen readers cannot use; Move left and Move right in the column menu do the same by keyboard.
  • In a ReorderableDataTable, rows move in the order of data: the drag handles rest while the table is sorted or grouped, with virtual rows, and until onRowOrderChange is given. A row moves within its page; sub-rows do not move.
  • Virtual rows must keep one height (virtual.rowHeight, else the size's row height). Row expansion and grouping are not virtualised, and a grouped table with virtual draws no group footer (its totals): leave virtual off for a grouped table that shows totals.
  • "{count} results" is one string for every number; translations that need plural forms should word it to fit any count.
  • Exported CSV writes raw values, not what cell draws; set meta.exportValue for a different text. A text that starts like a formula (=, +, -, @, a tab or a line break) is written with a leading apostrophe, so a spreadsheet shows it and never runs it; plain numbers such as -12.5 are left as they are. The Export CSV button saves the filtered rows of every page; with a server that pages, that is the page in data.

Keyboard

Keyboard
KeyBehaviour
EnterOn a column title, sorts by the column: ascending, then descending, then not sorted.
SpaceOn a column title, sorts the same way. On a row checkbox, selects or deselects the row; on the header box, every row of the page.
ShiftEnterOn a column title, adds the column to the sort, after the columns already sorted.
↓On a single-selection radio, selects the next row (ArrowUp the previous one).
EnterOn an expand button, expands or collapses the row or the group (Space too).
F2On an editable cell, opens its editor (Enter too). In the editor, Enter commits, Escape cancels; both return focus to the cell.
→On a resize handle, widens the column by 10 px (50 px with Shift); ArrowLeft narrows it. Swapped in a right-to-left page.
EnterOn a column's options button, opens its menu: Move left, Move right, Hide column.
SpaceIn a ReorderableDataTable, on a row's drag handle, picks the row up (Enter too); pressed again, drops it in its new place. Each step is announced.
↓While a row is picked up, moves it one place down (ArrowUp one place up).
EscapeWhile a row is picked up, puts it back where it was.

API

DataTable

DataTable props
PropTypeDefaultDescription
columnsrequiredreadonly DataTableColumnDef<TData, any>[]The columns, as TanStack Table 9 column definitions. Keep the array stable (module scope or useMemo).
datarequiredreadonly TData[]The rows: all of them, or the current page when the server pages (manualPagination). Keep the array stable.
aria-labelstringThe table's name, when it has no caption.
aria-labelledbystringThe id of the element that names the table.
captionReactNodeThe table's caption, shown under it.
classNamestringClasses on the outer element.
columnOrderingbooleanLets columns be moved by dragging their headers, or with Move left and Move right in each column's menu.
columnResizingbooleanLets each column be resized by dragging its edge, or with the arrow keys on its resize handle.
columnVisibilitybooleanLets columns be hidden: a Columns menu in the toolbar and a Hide item in each column's menu.
emptyReactNodeWhat the table shows when it has no rows.
exportCsvstring | booleanShows an Export CSV button in the toolbar; a string is the file's name.
filteringboolean | "columns" | "global"true gives a search field over every column and a filter row under the header; "global" or "columns" gives one.
getRowId((row: TData, index: number, parent?: DataTableRow<TData>) => string)A stable id for a row, such as its database id. Selection and expansion are kept by id. Defaults to its index.
getRowLabel((row: TData) => string)A row's name, for the names of its checkbox, radio, expand button and actions. Defaults to its first column's value.
getSubRows((row: TData, index: number) => readonly TData[])The child rows of a row, for a tree of rows that expand.
gridlinesbooleanDraws a line around every cell.
groupingbooleanGroups rows by the columns in the grouping state, each group under a header row that expands.
initialStatePartial<TableState_ColumnFiltering & TableState_ColumnGrouping & TableState_ColumnOrdering & ... 8 more ... & TableState_RowSorting>The state the table starts with, for the slices it controls itself.
loadingboolean | "overlay" | "skeleton"Shows that rows are loading: skeleton rows when there are none yet, else a spinner over them; or force one.
manualFilteringbooleanThe server filters: data comes filtered, and onGlobalFilterChange / onColumnFiltersChange say how.
manualPaginationbooleanThe server pages: data is the current page, and rowCount the total.
manualSortingbooleanThe server sorts: data comes sorted, and onSortingChange tells you the order to ask for.
maxHeightstring | numberThe height the table scrolls in, such as 400 or "60vh".
onCellEdit((edit: DataTableCellEdit<TData>) => void)Commits an edit of a cell whose column has meta.editor; update data with the new value.
onColumnFiltersChangeOnChangeFn<ColumnFiltersState>
onColumnOrderChangeOnChangeFn<ColumnOrderState>
onColumnPinningChangeOnChangeFn<ColumnPinningState>
onColumnSizingChangeOnChangeFn<ColumnSizingState>
onColumnVisibilityChangeOnChangeFn<ColumnVisibilityState>
onExpandedChangeOnChangeFn<ExpandedState>
onGlobalFilterChangeOnChangeFn<any>
onGroupingChangeOnChangeFn<GroupingState>
onPaginationChangeOnChangeFn<PaginationState>
onRowOrderChange((data: TData[], move: DataTableRowMove) => void)With rowReordering, called with data in its new order when a row is dropped in a new place; pass it back as data.
onRowSelectionChangeOnChangeFn<RowSelectionState>
onSortingChangeOnChangeFn<SortingState>
pageCountnumberThe number of pages on the server, when you know it instead of rowCount.
paginationboolean | { pageSizes?: number[]; range?: boolean; showEdges?: boolean | undefined; } | undefinedSplits the rows into pages with a paginator under the table. pageSizes adds a rows-per-page select, range the "11–20 of 120" text; showEdges: false leaves out the First and Last links.
renderGroupHeader((row: DataTableRow<TData>) => ReactNode)Draws a group's header row (a grouped table); defaults to the grouped value and the number of rows in it.
renderSubRow((row: DataTableRow<TData>) => ReactNode)Adds an expand button to each row; when expanded, the row is followed by what this returns.
rowCountnumberThe number of rows on the server, for the page count, when it pages.
rowReorderingbooleanAdds a drag handle at the start of each row. The handles move rows in a ReorderableDataTable, from @booleanpress/ui/data-table-reorder, which turns this on; a plain DataTable leaves the handles out.
selection"single" | "multiple"Adds a column of radios (single) or checkboxes with a select-all box (multiple).
selectOnRowClickbooleanWith selection, a press on a row selects it too (Shift extends a multiple selection). On by default.
size"default" | "sm" | "lg"The density: sm 26px rows, default 38px, lg 46px. Defaults to the provider's controlSize.
sortingbooleanSorts by a column when its title is pressed; Shift adds the column to the sort.
statePartial<TableState_ColumnFiltering & TableState_ColumnGrouping & TableState_ColumnOrdering & ... 8 more ... & TableState_RowSorting>State you control, slice by slice, each with its on…Change.
stickyHeaderbooleanKeeps the header in view while the rows scroll; give the table a maxHeight.
stripedbooleanFills every other row with the faintest surface.
tableOptionsPartial<TableOptions<{ columnFilteringFeature: TableFeature; globalFilteringFeature: TableFeature; rowSortingFeature: TableFeature; columnGroupingFeature: TableFeature; ... 18 more ...; tableMeta: DataTableMeta; }, TData>>Any other TanStack Table option (enableMultiSort, getRowCanExpand, meta…); it overrides the props above.
toolbarReactNodeYour content at the start of the toolbar above the table: a title, or buttons for the selected rows.
virtualboolean | { rowHeight?: number; overscan?: number; } | undefinedDraws only the rows in view, for thousands of rows; needs maxHeight. Rows keep one height.

DataTableColumnHeader

Renders a th and passes it every other prop.

DataTableColumnHeader props
PropTypeDefaultDescription
headerrequiredHeader<{ columnFilteringFeature: TableFeature; globalFilteringFeature: TableFeature; rowSortingFeature: TableFeature; columnGroupingFeature: TableFeature; ... 18 more ...; tableMeta: DataTableMeta; }, TData, unknown>The TanStack header to draw.
reorderablebooleanfalseLets the column be moved: dragged by its header, or with Move left and Move right in its menu.
resizablebooleantrueShows the resize handle when the column can resize; false for a last column that takes the space left.

DataTableToolbar

Renders a div and passes it every other prop.

DataTableToolbar props
PropTypeDefaultDescription
tablerequiredDataTableInstance<TData>The table it acts on.
columnsMenubooleanShows the Columns menu that hides and shows columns. Defaults to the table's column visibility.
exportCsvstring | booleanShows the Export CSV button; a string is the file's name.
searchbooleanShows the search field over every column. Defaults to the table's global filtering.

DataTablePagination

Renders a div and passes it every other prop.

DataTablePagination props
PropTypeDefaultDescription
tablerequiredDataTableInstance<TData>The table it pages.
pageSizesnumber[]The rows-per-page choices; leave out for no select.
rangebooleanfalseShows the rows the page holds, "11–20 of 120".
showEdgesbooleantrueShows the First and Last links round Previous and Next. true by default.

DataTableRowActions

DataTableRowActions props
PropTypeDefaultDescription
childrenrequiredReactNodeThe menu's items.
labelrequiredstringThe row's name, for the button's name ("Actions for t-1042").
align"center" | "start" | "end"endWhere the menu lines up with the button.
classNamestring

Also exported: useDataTable, a hook for the parts' shared state; call it inside the component's provider; createDataTableColumnHelper, a helper the parts use; exportToCsv, a helper the parts use; downloadCsv, a helper the parts use; DataTableProps, a TypeScript type; UseDataTableOptions, a TypeScript type; DataTableColumnDef, a TypeScript type; DataTableColumnMeta, a TypeScript type; DataTableMeta, a TypeScript type; DataTableOption, a TypeScript type; DataTableFeatures, a TypeScript type; DataTableInstance, a TypeScript type; DataTableRow, a TypeScript type; DataTableState, a TypeScript type; DataTableCellEdit, a TypeScript type; DataTableRowMove, a TypeScript type; ExportToCsvOptions, a TypeScript type.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="data-table-cell" (DataTable), data-slot="data-table-head" (DataTableColumnHeader), data-slot="data-table-toolbar" (DataTableToolbar), data-slot="data-table-pagination" (DataTablePagination), data-slot="data-table-row-actions" (DataTableRowActions), and data-resizing, data-drop-target, data-sorted, data-size, data-gridlines, data-striped.

Provider strings: selectAllRows, selectRow, collapseRow, expandRow, dragHandle, editCell, resizeColumn, columnMenu, moveColumnLeft, moveColumnRight, hideColumn, filterColumn, selectedCount, search, columns, exportCsv, rowActions, resultsCount, sortedBy, sortDescending, sortAscending, sortNone, pageRange, dragStarted, itemMoved, dragCancelled, noResults, noRows, loading (BooleanUIProvider's strings).

Theming

Header cells sit on --card in semibold --foreground, 0.5 × 0.875 rem padding (0.125 × 0.375 rem at sm, 0.75 × 1.125 rem at lg), over a 1 px --border line (--muted in dark). Rows sit on --card, take --accent under the pointer and --highlight with --highlight-foreground when selected; the selected row's lines turn --accent (--card in dark). striped fills every other row with --subtle (--background in dark). An unsorted arrow is --muted-foreground, darker on hover; a sorted one --foreground. The resize line and a column drop target are --primary. A drag handle is --control-hover, --secondary-foreground under the pointer; the row being dragged rises with the overlay shadow. The loading mask is --card at 50 % with a --primary spinner.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground, border
--accent-foregroundtext
--backgroundbackground
--borderborder
--cardbackground, border
--controlborder
--control-hoverborder, text
--fieldbackground
--field-disabledbackground
--foregroundtext
--highlightbackground
--highlight-foregroundtext
--mutedborder
--muted-foregroundtext
--primaryborder, text, background
--primary-foregroundbackground
--ringoutline, background
--secondary-foregroundtext
--subtlebackground