Data table
ProRows of records: search, sort, filter chips, column settings, selection and pages. Counts are of what's actually loaded ("Showing 1–10 of 248"). A blank cell shows a dash, never an empty gap that might be read as zero. Empty explains itself — no rows at all, or none matching the search.
Preview
Install
With the Candor CLI
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/data-table
Brings in: data, dropdown-menu, filter-chips, locale, skeleton, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { DataTable } from "@/components/data-table"
import type { Column, FilterChip } from "@/lib/data"
import { Button } from "@/components/ui/button"
type Migration = { id: string; name: string; repo: string; env: "Production" | "Staging"; rows: number | null; author: string; applied: string }
const migrations: Migration[] = [
{ id: "m142", name: "0142_add_region_to_projects", repo: "acme/api", env: "Staging", rows: 18_240, author: "Dev agent", applied: "2026-11-04" },
{ id: "m141", name: "0141_index_deployments_sha", repo: "acme/api", env: "Production", rows: null, author: "Dana", applied: "2026-11-02" },
{ id: "m140", name: "0140_drop_legacy_score", repo: "acme/api", env: "Production", rows: 9_812, author: "Jonas", applied: "2026-10-29" },
{ id: "m139", name: "0139_backfill_owner_id", repo: "acme/workers", env: "Production", rows: 42_100, author: "Dev agent", applied: "2026-10-27" },
{ id: "m138", name: "0138_add_flags_table", repo: "acme/web", env: "Production", rows: 0, author: "Mei", applied: "2026-10-22" },
{ id: "m137", name: "0137_split_invoice_lines", repo: "acme/api", env: "Staging", rows: 133_480, author: "Dev agent", applied: "2026-10-20" },
{ id: "m136", name: "0136_rename_plan_tier", repo: "acme/api", env: "Production", rows: 6_031, author: "Dana", applied: "2026-10-15" },
]
const columns: Column<Migration>[] = [
{ id: "name", label: "Migration", value: (m) => m.name, render: (m) => <code className="font-mono text-xs">{m.name}</code> },
{ id: "repo", label: "Repository", value: (m) => m.repo },
{ id: "env", label: "Environment", value: (m) => m.env },
{ id: "rows", label: "Rows touched", value: (m) => m.rows, numeric: true },
{ id: "author", label: "Author", value: (m) => m.author, optional: true },
{ id: "applied", label: "Applied", value: (m) => m.applied },
]
export default function Example() {
const [filters, setFilters] = React.useState<FilterChip[]>([{ id: "repo", label: "Repository", value: "Not acme/web" }])
const rows = filters.some((f) => f.id === "repo") ? migrations.filter((m) => m.repo !== "acme/web") : migrations
return (
<DataTable<Migration>
title="Migrations"
columns={columns}
rows={rows}
rowId={(m) => m.id}
selectable
pageSize={5}
filters={filters}
onRemoveFilter={(id) => setFilters((all) => all.filter((f) => f.id !== id))}
onClearFilters={() => setFilters([])}
actions={(selected) => (
<Button size="xs" variant="outline" onClick={() => {}}>
Ask Keel about {selected.size}
</Button>
)}
/>
)
}<DataTable>
| Prop | Type | Default | Description |
|---|---|---|---|
| columnsrequired | Column<Row>[] | — | The columns: header, how to read and show a row's value, and whether it's hidden until turned on. |
| rowsrequired | Row[] | — | The loaded records. Search, sort and paging work on these in the browser. |
| rowIdrequired | (row: Row) => string | — | A stable id for a row, used for keys and selection. |
| title | string | undefined | — | Heading over the table, also its accessible name. Defaults to "Records" for the name, with no heading shown. |
| loading | boolean | undefined | false | Rows are being fetched; shows skeleton rows instead of the empty message. |
| searchable | boolean | undefined | true | Shows a search box that matches any column's value. |
| filters | FilterChip[] | undefined | [] | Filters already applied, shown as removable chips. |
| onRemoveFilter | ((id: string) => void) | undefined | — | Called with a filter's id when its chip is removed. |
| onClearFilters | (() => void) | undefined | — | Called when a person clears all filters. |
| selectable | boolean | undefined | false | Adds a checkbox to each row and a select-all for the page. |
| selected | Set<string> | undefined | — | Ids of selected rows, when you control selection. Without it the table keeps its own. |
| onSelectedChange | ((ids: Set<string>) => void) | undefined | — | Called with the new set of selected ids when a person selects or clears rows. |
| onRowClick | ((row: Row) => void) | undefined | — | Called with the row a person clicks; rows look clickable when set. |
| pageSize | number | undefined | 10 | Rows per page. |
| empty | string | undefined | — | Shown when there are no rows at all (not when a search or filter matches nothing). |
| actions | ((selected: Set<string>) => React.ReactNode) | undefined | — | Shown in the header when rows are selected, e.g. bulk actions. |
| className | string | undefined | — | Extra classes for the outer element. |
Strings
The fixed words, in English by default. Change any of them under dataTable in <LocaleProvider strings={{ … }}>; the table is exported as dataTableStrings.
| Key | English |
|---|---|
| records | Records |
| empty | No records yet. |
| selected | {count} selected |
| search | Search |
| columns | Columns |
| showColumns | Show columns |
| selectAll | Select all on this page |
| selectRow | Select row |
| blank | blank |
| noMatch | Nothing matches. Try a different search or remove a filter. |
| showing | Showing {from}–{to} of {total} |
| inAll | ({total} in all) |
| previousPage | Previous page |
| nextPage | Next page |
| page | {page} / {pages} |