Data table
FreeAccessible table markup with consistent cell styling and a scrollable region.
pnpm dlx shadcn@latest add https://caliperui.site/r/data-table.json| Invoice | Customer | Status | |||
|---|---|---|---|---|---|
| INV-0417 | Halden Analytics | 4,280.00 | 2 Oct | Paid | |
| INV-0415 | Lumen Labs | 918.00 | 5 Oct | Failed | |
| INV-0416 | Ostra Freight | 12,960.50 | 14 Oct | Due 14 Oct | |
| INV-0414 | Northwind Studio | 7,125.00 | 18 Oct | Due 18 Oct | |
| INV-0418 | Kestrel Bio | 2,640.00 | 21 Oct | Draft |
Click a row, then use ↑ ↓ to move and x to select.
When to use #
Use it for
- Static tabular data that needs correct semantics.
- A base to build custom tables on.
Skip it when
- Sorting or selection is needed; use the sortable table.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/data-table.json
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above. It is built from DataTable, DataTableCell, DataTableHead, DataTableRow and DataTableBulkBar.
import { useState } from "react" import { DataTable, DataTableCell, DataTableHead, DataTableRow, DataTableBulkBar } from "@/components/ui/data-table" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" export function InvoiceTable() { const data = [ { id: "INV-0418", customer: "Kestrel Bio", amount: 2640, due: "2026-10-21", status: "Draft", tone: "neutral" as const }, { id: "INV-0417", customer: "Halden Analytics", amount: 4280, due: "2026-10-02", status: "Paid", tone: "success" as const }, { id: "INV-0416", customer: "Ostra Freight", amount: 12960.5, due: "2026-10-14", status: "Due 14 Oct", tone: "warning" as const }, { id: "INV-0415", customer: "Lumen Labs", amount: 918, due: "2026-10-05", status: "Failed", tone: "error" as const }, { id: "INV-0414", customer: "Northwind Studio", amount: 7125, due: "2026-10-18", status: "Due 18 Oct", tone: "warning" as const }, ]; const [sort, setSort] = useState<{ key: "amount" | "due"; dir: 1 | -1 }>({ key: "due", dir: 1 }); const [selected, setSelected] = useState<string[]>([]); const [cursor, setCursor] = useState(0); const [done, setDone] = useState(""); const rows = [...data].sort((a, b) => (sort.key === "amount" ? a.amount - b.amount : a.due.localeCompare(b.due)) * sort.dir); const money = (value: number) => value.toLocaleString("en-US", { minimumFractionDigits: 2 }); const total = rows.filter((row) => selected.includes(row.id)).reduce((sum, row) => sum + row.amount, 0); const toggle = (id: string) => setSelected((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id])); const flip = (key: "amount" | "due") => setSort((current) => ({ key, dir: current.key === key ? (current.dir === 1 ? -1 : 1) : key === "amount" ? -1 : 1 })); const state = (key: "amount" | "due") => (sort.key === key ? (sort.dir === 1 ? "ascending" : "descending") : "none"); return ( <div className="w-full max-w-2xl" onKeyDown={(event) => { if (event.key === "ArrowDown" || event.key === "j") setCursor((value) => Math.min(rows.length - 1, value + 1)); else if (event.key === "ArrowUp" || event.key === "k") setCursor((value) => Math.max(0, value - 1)); else if (event.key === "x") toggle(rows[cursor].id); else if (event.key === "Escape") setSelected([]); else return; event.preventDefault(); }} > <DataTable caption="Invoices due this month"> <thead> <tr> <DataTableHead className="w-10"><Checkbox aria-label="Select all invoices" checked={selected.length === rows.length} indeterminate={selected.length > 0 && selected.length < rows.length} onChange={(event) => setSelected(event.currentTarget.checked ? rows.map((row) => row.id) : [])} /></DataTableHead> <DataTableHead>Invoice</DataTableHead> <DataTableHead>Customer</DataTableHead> <DataTableHead numeric sort={state("amount")} onSort={() => flip("amount")}>Amount, USD</DataTableHead> <DataTableHead sort={state("due")} onSort={() => flip("due")}>Due</DataTableHead> <DataTableHead>Status</DataTableHead> </tr> </thead> <tbody> {rows.map((row, index) => ( <DataTableRow key={row.id} selected={selected.includes(row.id)} cursor={index === cursor} onClick={() => setCursor(index)}> <DataTableCell><Checkbox aria-label={`Select ${row.id}`} checked={selected.includes(row.id)} onChange={() => toggle(row.id)} /></DataTableCell> <DataTableCell className="font-mono text-[13px]">{row.id}</DataTableCell> <DataTableCell>{row.customer}</DataTableCell> <DataTableCell numeric className="font-medium">{money(row.amount)}</DataTableCell> <DataTableCell className="tabular-nums text-muted-foreground">{new Date(row.due).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</DataTableCell> <DataTableCell><Badge variant={row.tone}>{row.status}</Badge></DataTableCell> </DataTableRow> ))} </tbody> </DataTable> <DataTableBulkBar count={selected.length} summary={`$${money(total)}`}> <Button size="sm" onClick={() => { setDone(`Reminders sent for ${selected.length} invoice${selected.length === 1 ? "" : "s"}.`); setSelected([]); }}>Send reminder</Button> <Button size="sm">Export</Button> </DataTableBulkBar> <p role="status" className="mt-2 text-sm text-muted-foreground">{done || "Click a row, then use ↑ ↓ to move and x to select."}</p> </div> ); }
API reference #
DataTable
component
Also accepts the native props of its root element and a ref.
DataTableCell
component
Also accepts the native props of its root element and a ref.
DataTableHead
component
Also accepts the native props of its root element and a ref.
DataTableRow
component
Also accepts the native props of its root element and a ref.
DataTableBulkBar
component
Accessibility #
It exposes region and toolbar semantics, with aria-label, aria-sort, aria-hidden, aria-selected and aria-live kept in step with state. Changes that matter are announced through a live region.
No custom key handling; Tab and the native activation keys do the work.
Motion #
It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.