Data table
A table the model generates: typed columns that format currency, percents, dates and status, sorting, column resize, a sticky first column, rows that stream in without reflow, selection, density and virtualization for thousands of rows.
On this page
Installation
Usage
import { DataTable, type DataTableColumn } from "@/components/ui/chatcn/data-table"
const columns: DataTableColumn[] = [
{ id: "company", header: "Company", width: 220 },
{ id: "mrr", header: "MRR", type: "currency", currency: "USD" },
{ id: "seatChange", header: "Seat change", type: "percent", signed: true },
{ id: "renewal", header: "Renewal", type: "date" },
{
id: "health",
header: "Health",
type: "status",
statuses: { "at-risk": { label: "At risk", tone: "red" }, healthy: { label: "Healthy", tone: "green" } },
},
]
<DataTable title="Accounts at risk" columns={columns} rows={part.output.rows} />Rows are plain objects keyed by column id. The column type picks formatting, alignment and sorting: numbers, currency and percents align to the end in tabular figures, percents take fractions (-0.18 reads -18%), dates take ISO strings or epoch ms, and status values map to a label and a tone. Empty values show a quiet NULL badge and always sort last. Pass cell to render a column yourself, and getRowId when rows have no id.
Column widths are reserved from the definitions before any row arrives, so pass streaming while the model writes rows and the table only ever grows downward. Sort, selection, density and column widths are each controlled or uncontrolled.
Examples
Streaming rows
While streaming is true, a quiet Generating rows row holds the caret where the next row lands. New rows fade in over 150 ms; rows already on screen never move. The end of the stream is announced with the row count.
5,000 rows
Above 200 rows (change it with virtualizeAfter), only the rows in view render, measured as they mount so both densities scroll exactly. The header stays sticky and aria-rowcount and aria-rowindex keep each row's real position for screen readers.
Row selection
selectable adds a checkbox column with select all. Shift click selects a range. The header shows the count with Clear, and selectionActions renders your bulk actions next to it with the selected ids.
States
Props
| Prop | Type | Notes |
|---|---|---|
columns | DataTableColumn[] | Required. See the column table below |
rows | Row[] | Required. Objects keyed by column id |
getRowId | (row, index) => string | Defaults to row.id, then the index |
title | ReactNode | Defaults to "Results". null hides the header row |
actions | ReactNode | Extra header controls before the density toggle |
streaming | boolean | The model is still writing rows |
loading | boolean | Static skeleton rows in the final column layout |
error, onRetry | ReactNode, () => void | Replaces the rows. Say how to fix it |
emptyText | ReactNode | Defaults to "No rows" |
sort, defaultSort, onSortChange | { columnId, direction: "asc" | "desc" } | null | Header clicks cycle ascending, descending, none |
selectable | boolean | Checkbox column, select all and a count |
selectedIds, defaultSelectedIds, onSelectedIdsChange | string[] | Row ids |
selectionActions | (ids) => ReactNode | Shown while rows are selected |
density, defaultDensity, onDensityChange | "compact" | "comfortable" | 32 or 40 px rows. Defaults to compact |
densityToggle | boolean | Show the toggle. Defaults to true |
columnWidths, defaultColumnWidths, onColumnWidthsChange | Record<string, number> | Widths in px by column id |
stickyFirstColumn | boolean | Defaults to true |
maxHeight | number | string | Body height before it scrolls. Defaults to 440 |
virtualizeAfter | number | false | Defaults to 200 |
locale | string | For numbers and dates. Defaults to en-US |
variant | "card" | "plain" | plain sits inside a surface you already have |
DataTableColumn
| Field | Type | Notes |
|---|---|---|
id, header | string | Key in each row and the sentence case label |
type | "text" | "number" | "currency" | "percent" | "date" | "status" | Defaults to text |
width, minWidth | number | Px. Width defaults by type, minimum 72 |
align | "start" | "end" | Numbers align to the end by default |
currency | string | ISO code for currency columns. Defaults to USD |
decimals | number | Fraction digits |
signed | boolean | A plus sign on positive values, for changes |
dateFormat | Intl.DateTimeFormatOptions | Defaults to Apr 14, 2026 |
statuses | Record<string, { label, tone? }> | Tones: "green" | "red" | "amber" | "blue" | "neutral" |
wrap | boolean | Wrap long text onto more lines. Otherwise text truncates with an ellipsis and the full value in a tooltip |
sortable, resizable | boolean | Both default to true |
accessor, cell | (row) => unknown, (value, row) => ReactNode | Read or render |
Keyboard
| Key | Action |
|---|---|
Tab | Moves through sort buttons, resize handles, checkboxes and the scroll region |
Enter, Space | On a header, cycle the sort. On a checkbox, toggle the row |
ArrowLeft, ArrowRight | On a resize handle, narrow or widen the column by 16 px |
Shift + arrows | Resize by 64 px |
Home | On a resize handle, shrink the column to its minimum |
Enter on a resize handle | Reset the column to its defined width. Double click does the same |
Shift + click | Select every row between the last toggled row and this one |
| Arrow keys | Scroll the focused table region |
Accessibility
- A real table with column headers. The sorted header carries
aria-sort, and sort changes are announced once, for example Sorted by MRR, descending. - Resize handles are focusable separators with a label and their current width in
aria-valuenow. - Status cells pair the hued dot with a word. NULL reads as null.
- Rows carry
aria-rowindexand the tablearia-rowcount, so virtualized rows keep their real position. - The selected count sits in a polite live region. Checkboxes have a 32 px hit area, 44 px on touch.
- In right to left layouts the sticky column, the resize direction and alignment all follow the page.
Motion
- Almost none: sorting, resizing, selecting and switching density are instant because they happen dozens of times a session.
- Rows that arrive after mount fade in over 150 ms with no movement. Rows already on screen never animate again.
- The header glyph swaps between table, spinner and error with the kit's 150 ms blur crossfade. Under reduced motion it simply changes.