DataTable
Generic, token-driven table that renders native semantics and composes cell content per column.
How it works
DataTable renders real <table> semantics (thead / th[scope] / tbody /
td), so it is accessible without ARIA shims. The table owns structure and
styling only — each column's cell(row) composes its own content, so you can
drop a Badge, a colored amount, or a stacked
name/sub-label into any cell. It is generic over the row type T.
Usage
import { DataTable, type DataTableColumn } from '@cajon-ui/react';
interface Txn { name: string; amount: string; status: string; tone: 'success' | 'warning' }
const columns: DataTableColumn<Txn>[] = [
{ key: 'name', header: 'Transaction' },
{ key: 'amount', header: 'Amount', align: 'right' },
{ key: 'status', header: 'Status', align: 'right', cell: (t) => <Badge tone={t.tone}>{t.status}</Badge> },
];
<DataTable columns={columns} rows={txns} caption="Recent transactions" />When a column has no cell, the table reads row[key] directly.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
columns | DataTableColumn<T>[] | — | Column definitions, in display order |
rows | T[] | — | Flat row data; mutually exclusive with groups |
groups | DataTableGroup<T>[] | — | Expandable groups; mutually exclusive with rows |
getChildren | (row: T) => T[] | — | Recursive children in grouped mode |
expandedIds | string[] | — | Controlled expanded group and row ids |
onToggle | (id: string) => void | — | Disclosure callback |
indentPerLevel | number | 20 | First-cell indentation per tree depth |
groupActions | (group) => ReactNode | — | Trailing group-header controls |
rowKey | (row: T, i: number) => string | number | row index | Stable key per row |
rowProps | (row: T, i: number) => HTMLAttributes<HTMLTableRowElement> & { ref? } | — | Attributes spread onto the row element |
renderRow | (args: DataTableRowArgs<T>) => ReactNode | — | Supply the row element yourself — needed for per-row hooks |
dense | boolean | false | Tighter row padding |
dividers | boolean | true | Hairline dividers between rows |
caption | ReactNode | — | Accessible name, rendered as a <caption> |
columnHeader | show | hide | only | show | How the column header shows, for several tables that read as one list |
DataTableColumn<T>
| Field | Type | Default | Notes |
|---|---|---|---|
key | string | — | Unique key; also reads row[key] when no cell is given |
header | ReactNode | — | Header cell content |
align | left | center | right | left | Cell text alignment |
width | string | number | — | Fixed column width |
compact | boolean | false | Tighter horizontal padding for narrow columns such as grips, checkboxes and short IDs |
cell | (row: T) => ReactNode | — | Custom cell renderer |
One header over several tables
When rows are split into sections that each need their own table, such as bands
under headings, draw the column header once. Render one table with
columnHeader="only" above the sections, and give each section's table
columnHeader="hide". Hidden headers stay in the accessibility tree, so each
section is still announced with its column names. Both modes fix the column
widths, so columns line up across the tables; leave a column without a width
to take the space that is left.
<DataTable columnHeader="only" columns={columns} rows={[]} />
{sections.map((section) => (
<DataTable key={section.id} columnHeader="hide" columns={columns} rows={section.rows} />
))}Reaching the row
The table renders its own <tr>, so a consumer has no way to attach a ref, a
handler or a transform to a row. rowProps is that escape hatch: whatever it
returns is spread onto the row element.
<DataTable
columns={columns}
rows={rows}
rowProps={(row) => ({
onClick: () => select(row.id),
'data-state': row.status,
})}
/>ref passes through like any other prop, so a row can carry one without the
table knowing what it is for. This requires React 19, where refs are ordinary
props — and this package requires React 19.
Sortable rows
rowProps is not enough on its own. It is a callback this component invokes
inside its own render, so it gives you no component of your own per row — and
a hook must be called at a component's top level. useSortable therefore
cannot be called inside rowProps; doing so violates the Rules of Hooks
and throws as soon as the row count changes.
Use renderRow instead. You supply the row element, so you get a component
boundary and a legal place for the hook:
function SortableRow({ row, cells, rowProps }) {
const { setNodeRef, attributes, listeners, transform, transition } = useSortable({ id: row.id });
return (
<tr
{...rowProps}
{...attributes}
{...listeners}
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
{cells}
</tr>
);
}
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
<DataTable
columns={columns}
rows={rows}
rowKey={(row) => row.id}
renderRow={(args) => <SortableRow {...args} />}
/>
</SortableContext>Note there is no key on SortableRow. The table wraps whatever renderRow
returns and keys that wrapper from rowKey, so rowKey is what drives
reconciliation — a key of your own here is silently inert. Set rowKey to
something stable and identifying (it defaults to the array index, which is not
stable under reordering, and reordering is the reason you are here).
Expandable groups and trees
Use groups instead of rows when parent headers disclose child rows. The
same controlled expandedIds collection can hold group ids and nested row ids.
<DataTable
columns={columns}
groups={organizations}
rowKey={(account) => account.id}
getChildren={(account) => account.children}
expandedIds={expandedIds}
onToggle={toggleExpanded}
groupActions={(group) => <Button size="sm">Manage {group.header}</Button>}
/>indentPerLevel defaults to 20px and affects only the first cell, preserving
column alignment. An expanded empty group remains visible with its emptyHint
(or “No rows”), rather than disappearing. Group actions are isolated from the
disclosure control, so acting does not also collapse the group.
The cells arrive already rendered and rowProps arrives ready to spread, so a
custom row decorates rather than rebuilds — it cannot quietly drop a column or
the row class. Anything your rowProps callback returned is merged into the
rowProps handed to renderRow, so the two compose.
Which to reach for
rowProps for decoration — handlers, data-*, a style, a ref. renderRow
only when you need a component of your own per row, which in practice means a
per-row hook. Reaching for renderRow when rowProps would do gives up the
guarantee that every row in the system is built the same way.