Data display
Data table
Data table is a client-side table for real lists: global search, sortable columns, row selection with bulk actions, a Columns menu to show and hide columns, pagination and a helpful empty state. You describe columns once (how to read, sort and render each value) and the table does the rest. On small screens it scrolls sideways inside its frame while the toolbar and pagination wrap.
| INV-2078 | Copperleaf Dental | Paid | Sep 20, 2026 | $13,243.94 | |
| INV-2073 | Lumen Atelier | Pending | Sep 19, 2026 | $1,648.21 | |
| INV-2068 | Sundial Health | Paid | Sep 18, 2026 | $4,053.45 | |
| INV-2063 | Brindle Coffee Co. | Pending | Sep 17, 2026 | $6,458.69 | |
| INV-2058 | Quillmark | Paid | Sep 16, 2026 | $8,863.93 | |
| INV-2053 | Halden Works | Pending | Sep 15, 2026 | $11,268.20 | |
| INV-2048 | Marlow & Finch | Paid | Sep 14, 2026 | $13,673.44 | |
| INV-2043 | Vantor Freight | Pending | Sep 13, 2026 | $2,078.68 | |
| INV-2080 | Kettle & Loom | Draft | Sep 2, 2026 | $1,081.65 | |
| INV-2075 | Tidewell Logistics | Pending | Sep 1, 2026 | $3,486.89 |
1–10 of 40 invoices
const [rows, setRows] = useState(invoices); <DataTable label="Invoices" rowNoun="invoice" data={rows} columns={invoiceColumns} getRowId={(r) => r.id} selectable initialSort={{ id: "issued", direction: "desc" }} searchPlaceholder="Search invoices…" toolbar={ <Button variant="primary" iconLeft={<Plus strokeWidth={1.75} />}> New invoice </Button> } bulkActions={(selected, clear) => ( <> <Button size="sm" variant="secondary" onClick={() => { const ids = new Set(selected.map((r) => r.id)); setRows(rows.map((r) => (ids.has(r.id) ? { ...r, status: "Paid" } : r))); clear(); }} > Mark as paid </Button> <Button size="sm" variant="ghost" iconLeft={<Download strokeWidth={1.75} />}> Export </Button> <Button size="sm" variant="ghost" iconLeft={<Trash2 strokeWidth={1.75} />} onClick={() => { const ids = new Set(selected.map((r) => r.id)); setRows(rows.filter((r) => !ids.has(r.id))); clear(); }} > Delete </Button> </> )}/>Usage
import { DataTable, type DataTableColumn } from "@/components/ui/data-table"; const columns: DataTableColumn<Invoice>[] = [ { id: "id", header: "Invoice", accessor: (r) => r.id }, { id: "amount", header: "Amount", accessor: (r) => r.amount, numeric: true, cell: (r) => usd(r.amount) },]; <DataTable label="Invoices" data={invoices} columns={columns} getRowId={(r) => r.id} selectable />Examples
Overview
40 invoices with search, sorting (newest first), selection with bulk actions, Email and Due columns hidden in the Columns menu, and pagination. Select a few rows and mark them as paid.
| INV-2078 | Copperleaf Dental | Paid | Sep 20, 2026 | $13,243.94 | |
| INV-2073 | Lumen Atelier | Pending | Sep 19, 2026 | $1,648.21 | |
| INV-2068 | Sundial Health | Paid | Sep 18, 2026 | $4,053.45 | |
| INV-2063 | Brindle Coffee Co. | Pending | Sep 17, 2026 | $6,458.69 | |
| INV-2058 | Quillmark | Paid | Sep 16, 2026 | $8,863.93 | |
| INV-2053 | Halden Works | Pending | Sep 15, 2026 | $11,268.20 | |
| INV-2048 | Marlow & Finch | Paid | Sep 14, 2026 | $13,673.44 | |
| INV-2043 | Vantor Freight | Pending | Sep 13, 2026 | $2,078.68 | |
| INV-2080 | Kettle & Loom | Draft | Sep 2, 2026 | $1,081.65 | |
| INV-2075 | Tidewell Logistics | Pending | Sep 1, 2026 | $3,486.89 |
1–10 of 40 invoices
const [rows, setRows] = useState(invoices); <DataTable label="Invoices" rowNoun="invoice" data={rows} columns={invoiceColumns} getRowId={(r) => r.id} selectable initialSort={{ id: "issued", direction: "desc" }} searchPlaceholder="Search invoices…" toolbar={ <Button variant="primary" iconLeft={<Plus strokeWidth={1.75} />}> New invoice </Button> } bulkActions={(selected, clear) => ( <> <Button size="sm" variant="secondary" onClick={() => { const ids = new Set(selected.map((r) => r.id)); setRows(rows.map((r) => (ids.has(r.id) ? { ...r, status: "Paid" } : r))); clear(); }} > Mark as paid </Button> <Button size="sm" variant="ghost" iconLeft={<Download strokeWidth={1.75} />}> Export </Button> <Button size="sm" variant="ghost" iconLeft={<Trash2 strokeWidth={1.75} />} onClick={() => { const ids = new Set(selected.map((r) => r.id)); setRows(rows.filter((r) => !ids.has(r.id))); clear(); }} > Delete </Button> </> )}/>Defining columns
accessor returns the raw value used for sorting and search (strings, numbers, dates); cell renders it. numeric right-aligns with tabular figures, searchable: false leaves a column out of the search, defaultHidden starts it hidden and hideable: false keeps it visible. This list sorts by last activity and shows five rows per page.
| Ada Moreno | ada@ledgerly.example | Owner | 3 | 2 min ago |
| Felix Brandt | felix@ledgerly.example | Member | 2 | 15 min ago |
| Theo Lindqvist | theo@ledgerly.example | Admin | 2 | 45 min ago |
| Priya Raman | priya@ledgerly.example | Member | 1 | 3 h ago |
| Sana Ibrahim | sana@ledgerly.example | Admin | 1 | 10 h ago |
1–5 of 7 members
const memberColumns: DataTableColumn<Member>[] = [ { id: "name", header: "Name", accessor: (m) => m.name, cell: (m) => <span className="font-medium">{m.name}</span> }, { id: "email", header: "Email", accessor: (m) => m.email, cell: (m) => <span className="text-muted">{m.email}</span> }, { id: "role", header: "Role", accessor: (m) => m.role, cell: (m) => <Badge tone={m.role === "Owner" ? "accent" : "neutral"}>{m.role}</Badge> }, { id: "seats", header: "Seats", accessor: (m) => m.seats, numeric: true }, { id: "lastActive", header: "Last active", accessor: (m) => m.lastActive, searchable: false, cell: (m) => <span className="text-muted">{ago(m.lastActive)}</span> },]; <DataTable label="Team members" rowNoun="member" data={members} columns={memberColumns} getRowId={(m) => m.id} pageSize={5} pageSizeOptions={[5, 10]} initialSort={{ id: "lastActive", direction: "asc" }} searchPlaceholder="Search people…"/>Empty state
emptyState shows when there is no data at all. When a search has no matches the table shows its own message with a Clear search button.
No invoices yet
Invoices you create or import will be listed here.
No invoices
<DataTable label="Invoices" rowNoun="invoice" data={[]} columns={invoiceColumns} getRowId={(r) => r.id} selectable emptyState={ <EmptyState size="sm" icon={<FileText strokeWidth={1.75} />} title="No invoices yet" description="Invoices you create or import will be listed here." action={ <Button variant="primary" size="sm" iconLeft={<Plus strokeWidth={1.75} />}> Create invoice </Button> } /> }/>Props
DataTable
datarequiredT[]default: —Rows.columnsrequiredDataTableColumn<T>[]default: —Column definitions (see below).getRowIdrequired(row: T) => stringdefault: —Stable id per row, used for selection and keys.labelrequiredstringdefault: —Accessible name of the table, e.g. "Invoices".rowNounstringdefault: "row"Used in counts and checkbox labels ("3 invoices selected").selectablebooleandefault: falseAdds a checkbox column and the selection bar.selected / onSelectionChangestring[] / (ids) => voiddefault: —Controlled selection, or just listen to changes.bulkActions(rows: T[], clear: () => void) => ReactNodedefault: —Actions shown in the selection bar.searchablebooleandefault: trueShow the global search field.searchPlaceholderstringdefault: "Search…"Placeholder of the search field.initialSort{ id: string; direction: "asc" | "desc" } | nulldefault: nullStarting sort.pageSize / pageSizeOptionsnumber / number[]default: 10 / [10, 20, 50]Rows per page and the choices offered.toolbarReactNodedefault: —Extra controls next to the Columns menu.emptyStateReactNodedefault: —Shown when data is empty.
DataTableColumn<T>
id / headerrequiredstringdefault: —Unique id and header text.accessor(row: T) => string | number | Date | boolean | nulldefault: —Raw value for sorting and search.cell(row: T) => ReactNodedefault: —Custom rendering. Defaults to the accessor value.sortablebooleandefault: true with accessorAllow sorting by this column.searchablebooleandefault: true for text and numbersInclude in the global search.numericbooleandefault: falseRight-aligned tabular figures.align"left" | "center" | "right"default: "left"Cell alignment.hideable / defaultHiddenbooleandefault: true / falseColumns menu behavior.width / classNamenumber | string / stringdefault: —Column width and extra cell classes.
Accessibility
- Sortable headers are buttons inside the th, and the th carries aria-sort ("ascending", "descending" or "none"); the caption also states the current sort.
- Every checkbox has a label ("Select invoice INV-2041", "Select all invoices on this page"), and the header checkbox shows a mixed state when only some rows are selected.
- The Columns menu follows the menu button pattern: Enter, Space or Down opens it, arrows move, Home and End jump, Escape closes and returns focus; items are menuitemcheckbox with aria-checked.
- The search field has a label, and the result count is a polite live region, so filtering is announced.
- Pagination is a labelled nav with named icon buttons; the scroll area is a focusable region so keyboard users can scroll wide tables.