Skip to content
Sign in
DataData tableMenu

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.

Typed columns that format themselves: currency, signed percent, dates and status, sorted by MRR.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/data-table.json

Usage

tsx
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.

Rows arrive as the model writes them under column widths reserved up front, so nothing reflows.

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.

Virtualized above 200 rows, with the sticky header and the row count kept 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.

Checkboxes, select all, shift click ranges and a bulk action next to the selected count.

States

Loading, empty, error with retry, comfortable density and long content.

Props

PropTypeNotes
columnsDataTableColumn[]Required. See the column table below
rowsRow[]Required. Objects keyed by column id
getRowId(row, index) => stringDefaults to row.id, then the index
titleReactNodeDefaults to "Results". null hides the header row
actionsReactNodeExtra header controls before the density toggle
streamingbooleanThe model is still writing rows
loadingbooleanStatic skeleton rows in the final column layout
error, onRetryReactNode, () => voidReplaces the rows. Say how to fix it
emptyTextReactNodeDefaults to "No rows"
sort, defaultSort, onSortChange{ columnId, direction: "asc" | "desc" } | nullHeader clicks cycle ascending, descending, none
selectablebooleanCheckbox column, select all and a count
selectedIds, defaultSelectedIds, onSelectedIdsChangestring[]Row ids
selectionActions(ids) => ReactNodeShown while rows are selected
density, defaultDensity, onDensityChange"compact" | "comfortable"32 or 40 px rows. Defaults to compact
densityTogglebooleanShow the toggle. Defaults to true
columnWidths, defaultColumnWidths, onColumnWidthsChangeRecord<string, number>Widths in px by column id
stickyFirstColumnbooleanDefaults to true
maxHeightnumber | stringBody height before it scrolls. Defaults to 440
virtualizeAfternumber | falseDefaults to 200
localestringFor numbers and dates. Defaults to en-US
variant"card" | "plain"plain sits inside a surface you already have

DataTableColumn

FieldTypeNotes
id, headerstringKey in each row and the sentence case label
type"text" | "number" | "currency" | "percent" | "date" | "status"Defaults to text
width, minWidthnumberPx. Width defaults by type, minimum 72
align"start" | "end"Numbers align to the end by default
currencystringISO code for currency columns. Defaults to USD
decimalsnumberFraction digits
signedbooleanA plus sign on positive values, for changes
dateFormatIntl.DateTimeFormatOptionsDefaults to Apr 14, 2026
statusesRecord<string, { label, tone? }>Tones: "green" | "red" | "amber" | "blue" | "neutral"
wrapbooleanWrap long text onto more lines. Otherwise text truncates with an ellipsis and the full value in a tooltip
sortable, resizablebooleanBoth default to true
accessor, cell(row) => unknown, (value, row) => ReactNodeRead or render

Keyboard

KeyAction
TabMoves through sort buttons, resize handles, checkboxes and the scroll region
Enter, SpaceOn a header, cycle the sort. On a checkbox, toggle the row
ArrowLeft, ArrowRightOn a resize handle, narrow or widen the column by 16 px
Shift + arrowsResize by 64 px
HomeOn a resize handle, shrink the column to its minimum
Enter on a resize handleReset the column to its defined width. Double click does the same
Shift + clickSelect every row between the last toggled row and this one
Arrow keysScroll 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-rowindex and the table aria-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.