# 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`
- **APG Table:** <https://www.w3.org/WAI/ARIA/apg/patterns/table/>
- **Page:** <https://ui.booleanpress.com/components/data-table> · @booleanpress/ui 0.2.0

## 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`.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| Key | Behaviour |
| --- | --- |
| Enter | On a column title, sorts by the column: ascending, then descending, then not sorted. |
| Space | On 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. |
| Shift + Enter | On 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). |
| Enter | On an expand button, expands or collapses the row or the group (Space too). |
| F2 | On 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. |
| Enter | On a column's options button, opens its menu: Move left, Move right, Hide column. |
| Space | In 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). |
| Escape | While a row is picked up, puts it back where it was. |

## API

### DataTable

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` (required) | `readonly DataTableColumnDef<TData, any>[]` |  | The columns, as TanStack Table 9 column definitions. Keep the array stable (module scope or `useMemo`). |
| `data` (required) | `readonly TData[]` |  | The rows: all of them, or the current page when the server pages (`manualPagination`). Keep the array stable. |
| `aria-label` | `string` |  | The table's name, when it has no caption. |
| `aria-labelledby` | `string` |  | The id of the element that names the table. |
| `caption` | `ReactNode` |  | The table's caption, shown under it. |
| `className` | `string` |  | Classes on the outer element. |
| `columnOrdering` | `boolean` |  | Lets columns be moved by dragging their headers, or with Move left and Move right in each column's menu. |
| `columnResizing` | `boolean` |  | Lets each column be resized by dragging its edge, or with the arrow keys on its resize handle. |
| `columnVisibility` | `boolean` |  | Lets columns be hidden: a Columns menu in the toolbar and a Hide item in each column's menu. |
| `empty` | `ReactNode` |  | What the table shows when it has no rows. |
| `exportCsv` | `string \| boolean` |  | Shows an Export CSV button in the toolbar; a string is the file's name. |
| `filtering` | `boolean \| "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. |
| `gridlines` | `boolean` |  | Draws a line around every cell. |
| `grouping` | `boolean` |  | Groups rows by the columns in the `grouping` state, each group under a header row that expands. |
| `initialState` | `Partial<TableState_ColumnFiltering & TableState_ColumnGrouping & TableState_ColumnOrdering & ... 8 more ... & TableState_RowSorting>` |  | The state the table starts with, for the slices it controls itself. |
| `loading` | `boolean \| "overlay" \| "skeleton"` |  | Shows that rows are loading: skeleton rows when there are none yet, else a spinner over them; or force one. |
| `manualFiltering` | `boolean` |  | The server filters: `data` comes filtered, and `onGlobalFilterChange` / `onColumnFiltersChange` say how. |
| `manualPagination` | `boolean` |  | The server pages: `data` is the current page, and `rowCount` the total. |
| `manualSorting` | `boolean` |  | The server sorts: `data` comes sorted, and `onSortingChange` tells you the order to ask for. |
| `maxHeight` | `string \| number` |  | The 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. |
| `onColumnFiltersChange` | `OnChangeFn<ColumnFiltersState>` |  |  |
| `onColumnOrderChange` | `OnChangeFn<ColumnOrderState>` |  |  |
| `onColumnPinningChange` | `OnChangeFn<ColumnPinningState>` |  |  |
| `onColumnSizingChange` | `OnChangeFn<ColumnSizingState>` |  |  |
| `onColumnVisibilityChange` | `OnChangeFn<ColumnVisibilityState>` |  |  |
| `onExpandedChange` | `OnChangeFn<ExpandedState>` |  |  |
| `onGlobalFilterChange` | `OnChangeFn<any>` |  |  |
| `onGroupingChange` | `OnChangeFn<GroupingState>` |  |  |
| `onPaginationChange` | `OnChangeFn<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`. |
| `onRowSelectionChange` | `OnChangeFn<RowSelectionState>` |  |  |
| `onSortingChange` | `OnChangeFn<SortingState>` |  |  |
| `pageCount` | `number` |  | The number of pages on the server, when you know it instead of `rowCount`. |
| `pagination` | `boolean \| { pageSizes?: number[]; range?: boolean; showEdges?: boolean \| undefined; } \| undefined` |  | Splits 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. |
| `rowCount` | `number` |  | The number of rows on the server, for the page count, when it pages. |
| `rowReordering` | `boolean` |  | Adds 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`). |
| `selectOnRowClick` | `boolean` |  | With `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`. |
| `sorting` | `boolean` |  | Sorts by a column when its title is pressed; Shift adds the column to the sort. |
| `state` | `Partial<TableState_ColumnFiltering & TableState_ColumnGrouping & TableState_ColumnOrdering & ... 8 more ... & TableState_RowSorting>` |  | State you control, slice by slice, each with its `on…Change`. |
| `stickyHeader` | `boolean` |  | Keeps the header in view while the rows scroll; give the table a `maxHeight`. |
| `striped` | `boolean` |  | Fills every other row with the faintest surface. |
| `tableOptions` | `Partial<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. |
| `toolbar` | `ReactNode` |  | Your content at the start of the toolbar above the table: a title, or buttons for the selected rows. |
| `virtual` | `boolean \| { rowHeight?: number; overscan?: number; } \| undefined` |  | Draws 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `header` (required) | `Header<{ columnFilteringFeature: TableFeature; globalFilteringFeature: TableFeature; rowSortingFeature: TableFeature; columnGroupingFeature: TableFeature; ... 18 more ...; tableMeta: DataTableMeta; }, TData, unknown>` |  | The TanStack header to draw. |
| `reorderable` | `boolean` | `false` | Lets the column be moved: dragged by its header, or with Move left and Move right in its menu. |
| `resizable` | `boolean` | `true` | Shows 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `table` (required) | `DataTableInstance<TData>` |  | The table it acts on. |
| `columnsMenu` | `boolean` |  | Shows the Columns menu that hides and shows columns. Defaults to the table's column visibility. |
| `exportCsv` | `string \| boolean` |  | Shows the Export CSV button; a string is the file's name. |
| `search` | `boolean` |  | Shows the search field over every column. Defaults to the table's global filtering. |

### DataTablePagination

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `table` (required) | `DataTableInstance<TData>` |  | The table it pages. |
| `pageSizes` | `number[]` |  | The rows-per-page choices; leave out for no select. |
| `range` | `boolean` | `false` | Shows the rows the page holds, "11–20 of 120". |
| `showEdges` | `boolean` | `true` | Shows the First and Last links round Previous and Next. `true` by default. |

### DataTableRowActions

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` |  | The menu's items. |
| `label` (required) | `string` |  | The row's name, for the button's name ("Actions for t-1042"). |
| `align` | `"center" \| "start" \| "end"` | `end` | Where the menu lines up with the button. |
| `className` | `string` |  |  |

**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.

| Token | Used for |
| --- | --- |
| `--accent` | background, border |
| `--accent-foreground` | text |
| `--background` | background |
| `--border` | border |
| `--card` | background, border |
| `--control` | border |
| `--control-hover` | border, text |
| `--field` | background |
| `--field-disabled` | background |
| `--foreground` | text |
| `--highlight` | background |
| `--highlight-foreground` | text |
| `--muted` | border |
| `--muted-foreground` | text |
| `--primary` | border, text, background |
| `--primary-foreground` | background |
| `--ring` | outline, background |
| `--secondary-foreground` | text |
| `--subtle` | background |
