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
tablewiththead,tbody,th scope="col"andtd, so a screen reader announces each cell with its column. A sortable column's title is abutton, and itsthcarriesaria-sort(ascending,descendingornone; with several sort columns, only the first). A column without a title (selection, expansion, actions) has an emptytdin the header row, not an empty header. Virtual rows keeparia-rowcounton the table andaria-rowindexon every row. - Labels
- Name the table with
aria-label,aria-labelledbyorcaption. 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}" witharia-expanded, resize handles "Resize {column}" (role="separator"witharia-valuenowin 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'sdragHandle). A row's name isgetRowLabel, 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 indata. - 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 ofdata: the drag handles rest while the table is sorted or grouped, with virtual rows, and untilonRowOrderChangeis 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 withvirtualdraws no group footer (its totals): leavevirtualoff 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
celldraws; setmeta.exportValuefor 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 indata.
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. |
| ShiftEnter | 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 |
|---|---|---|---|
columnsrequired | readonly DataTableColumnDef<TData, any>[] | The columns, as TanStack Table 9 column definitions. Keep the array stable (module scope or useMemo). | |
datarequired | 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 |
|---|---|---|---|
headerrequired | 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 |
|---|---|---|---|
tablerequired | 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 |
|---|---|---|---|
tablerequired | 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 |
|---|---|---|---|
childrenrequired | ReactNode | The menu's items. | |
labelrequired | 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.
The tokens its classes read. Change their values in your theme, and it follows.
| 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 |