Cajón Design System
Components

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

PropTypeDefaultNotes
columnsDataTableColumn<T>[]—Column definitions, in display order
rowsT[]—Flat row data; mutually exclusive with groups
groupsDataTableGroup<T>[]—Expandable groups; mutually exclusive with rows
getChildren(row: T) => T[]—Recursive children in grouped mode
expandedIdsstring[]—Controlled expanded group and row ids
onToggle(id: string) => void—Disclosure callback
indentPerLevelnumber20First-cell indentation per tree depth
groupActions(group) => ReactNode—Trailing group-header controls
rowKey(row: T, i: number) => string | numberrow indexStable 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
densebooleanfalseTighter row padding
dividersbooleantrueHairline dividers between rows
captionReactNode—Accessible name, rendered as a <caption>
columnHeadershow | hide | onlyshowHow the column header shows, for several tables that read as one list

DataTableColumn<T>

FieldTypeDefaultNotes
keystring—Unique key; also reads row[key] when no cell is given
headerReactNode—Header cell content
alignleft | center | rightleftCell text alignment
widthstring | number—Fixed column width
compactbooleanfalseTighter 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.

On this page