Source: https://monarchui.dev/components/data-table/

Components · Tables and data grid

# DataTable

Column-driven data table with sorting, selection, column controls, pagination, states and keyboard grid navigation; plus the Table parts it is built on.

@rookcom/monarch-react/data

Component

## Sorting, selection, row actions and grid keyboard

## Usage examples

### Server-driven sorting and pagination

### Column chooser, resizing and expandable rows

### Loading, filtered empty and error states

### Card layout for narrow screens

### Table parts (custom tables)

## Purpose

Shows records in rows and columns that people compare, sort, select and act on. `DataTable` takes column definitions and rows and handles sorting, selection, column visibility, order and width, pagination, row actions, expandable rows, loading, empty and error states, keyboard navigation and a card layout for phones. The `Table*` parts are the styled native table it is built on, for fully custom tables.

### Use when

- Tabular records the user compares across columns (tickets, invoices, members).
- Rows are sorted, selected for bulk actions, paged, or opened.
- The data comes from a server that sorts and pages (`manualSorting`, `manualPagination`).

### Avoid when

- Label/value pairs about one record: use DescriptionList.
- A list without columns: use a list of rows or cards.
- Spreadsheet-style editing of every cell: that needs a dedicated editable grid.

## Design principles

- Native `<table>` semantics first: caption, `scope="col"` headers, the first column as row headers (`scope="row"`), and `aria-sort` on the primary sorted column.
- No business logic: the app owns data, filters and persistence. DataTable sorts and pages client-side only when the app has not taken that over, and always reports intent through callbacks.
- Every state is controlled or uncontrolled: `sort`/`defaultSort`, `selectedRowIds`/`defaultSelectedRowIds`, `page`/`defaultPage`, `expandedRowIds`/`defaultExpandedRowIds`, `columnWidths`/`defaultColumnWidths`.
- Built from Monarch parts: Checkbox, DropdownMenu, Pagination, EmptyState, LoadingState and ErrorState, so states look the same as everywhere else.
- Every control has a row-specific name ("Select INV-1042 for Northfield Traders", "Actions for …").

## API reference

```tsx
import { DataTable, DataTableColumnChooser, SortButton, Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, TableSelectCell, columnChooserColumns, defaultColumnChooserMessages, defaultDataTableMessages, nextSort, sortRows, useDataTableColumns } from '@rookcom/monarch-react/data';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `DataTable` | component | client | `.mn-grid-skip` |
| `DataTableColumnChooser` | component | client | `.mn-grid-skip` |
| `SortButton` | component | client | `.mn-table-wrap` |
| `Table` | component | client | `.mn-table-wrap` |
| `TableBody` | component | client | `.mn-table-wrap` |
| `TableCaption` | component | client | `.mn-table-wrap` |
| `TableCell` | component | client | `.mn-table-wrap` |
| `TableHead` | component | client | `.mn-table-wrap` |
| `TableHeader` | component | client | `.mn-table-wrap` |
| `TableRow` | component | client | `.mn-table-wrap` |
| `TableSelectCell` | component | client | `.mn-table-wrap` |
| `columnChooserColumns` | utility | client | – |
| `defaultColumnChooserMessages` | utility | client | – |
| `defaultDataTableMessages` | utility | client | – |
| `nextSort` | utility | client | – |
| `sortRows` | utility | client | – |
| `useDataTableColumns` | hook | client | – |

`DataTable`, `DataTableColumnChooser`, `useDataTableColumns`, `sortRows`, `nextSort` and `columnChooserColumns` are imported from `@rookcom/monarch-react/data`. The `Table*` parts and `SortButton` are exported from both `@rookcom/monarch-react` (unchanged) and `@rookcom/monarch-react/data`.

`DataTable` is generic over the row type: `DataTable<Invoice>` checks `columns`, `getRowId` and every callback against it. Its ref is the outer `div`.

`DataTableColumn<T>`: `id`, `header`, optional `label`, `cell`, `accessor`, `sortable`, `sortFn`, `numeric`, `width`/`minWidth`/`maxWidth`, `resizable`, `hideable`, `defaultHidden`, `hideBelow`, `rowHeader`.

`sortRows(rows, sort, columns)` is the client-side sort DataTable uses (stable, numeric-aware, empty values last); `nextSort(sort, id, additive)` is the header cycle (ascending → descending → off).

`messages` replaces any user-visible string, for translation.

`Table` gains two additive props: `stickyFirstColumn` and `maxHeight`.

Generated from the TypeScript declarations of `packages/react/src/components/data-grid/data-table`.

## Properties

### `DataTable`

A data table driven by column definitions: sorting (single or multi), row selection with select-all-on-page, column visibility, order and resizing, sticky header and first column, pagination (client or server), expandable rows, row actions, built-in loading, empty and error states, keyboard grid navigation and a stacked card layout for narrow screens. Built on the Table parts.

| Prop | Type | Description |
| --- | --- | --- |
| `caption` required | `string` | Accessible name of the table. Rendered as a `<caption>`. |
| `columns` required | `readonly DataTableColumn<T>[]` |  |
| `getRowId` required | `(row: T, index: number) => string` | Stable id of a row. |
| `rows` required | `readonly T[]` |  |
| `allMatchingSelected` | `boolean` | Every matching row (not just this page) is selected; shows the banner state. |
| `cardBreakpoint` | `number` | Width in px below which `layout="auto"` shows cards. Default 640. |
| `className` | `string` |  |
| `columnOrder` | `readonly string[]` | Column ids in display order (controlled). Unknown ids are ignored, missing ones appended. |
| `columnVisibility` | `Readonly<Record<string, boolean>>` | Visible columns by id (controlled). Missing ids fall back to `!defaultHidden`. |
| `columnWidths` | `Readonly<Record<string, number>>` | Column widths in px by id (controlled). |
| `defaultColumnOrder` | `readonly string[]` |  |
| `defaultColumnVisibility` | `Readonly<Record<string, boolean>>` | Initial visibility (uncontrolled; change it with `columnVisibility` and a DataTableColumnChooser). |
| `defaultColumnWidths` | `Readonly<Record<string, number>>` |  |
| `defaultExpandedRowIds` | `readonly string[]` |  |
| `defaultPage` | `number` |  |
| `defaultSelectedRowIds` | `readonly string[]` | Initially selected row ids (uncontrolled). |
| `defaultSort` | `readonly DataTableSort[]` | Initial sort (uncontrolled). |
| `density` | `"compact" \| "comfortable"` |  |
| `emptyState` | `ReactNode` | Replaces the built-in empty state. |
| `error` | `ReactNode` | Loading failed. A string or node becomes the description. Without rows it replaces the body. |
| `expandedRowIds` | `readonly string[]` |  |
| `filtered` | `boolean` | The rows are empty because of filters: shows the filtered empty state. |
| `footer` | `ReactNode` | Content under the table, e.g. a LoadMore button. |
| `getRowLabel` | `((row: T) => string)` | Plain-text name of a row for checkbox, menu and expand labels ("Select Acme"). Default: the id. |
| `id` | `string` |  |
| `isRowExpandable` | `((row: T) => boolean)` |  |
| `isRowSelectable` | `((row: T) => boolean)` | Rows that cannot be selected get a disabled checkbox. |
| `keyboardNavigation` | `boolean` | Keyboard grid navigation (WAI-ARIA grid): the table becomes `role="grid"` with one tab stop and arrow-key movement between cells. Default: on when `onRowOpen` is set. |
| `layout` | `"table" \| "auto" \| "cards"` | `table` scrolls sideways on narrow screens; `cards` always renders a stacked list; `auto` switches to cards below `cardBreakpoint`. |
| `loading` | `boolean` | Rows are loading. Without rows a skeleton fills the body; with rows they stay and the table is busy. |
| `manualPagination` | `boolean` | The rows are already the current page (server-side pagination). Needs `totalRowCount`. |
| `manualSorting` | `boolean` | The rows arrive sorted (server-side sorting): the table only reports sort intent. |
| `maxHeight` | `string \| number` | Maximum height of the scroll area. |
| `messages` | `Partial<DataTableMessages>` |  |
| `multiSort` | `boolean` | Shift+click (or Shift+Enter) adds a column to the sort. Default false. |
| `onClearFilters` | `(() => void)` |  |
| `onColumnWidthsChange` | `((widths: Record<string, number>) => void)` |  |
| `onExpandedRowIdsChange` | `((ids: string[]) => void)` |  |
| `onPageChange` | `((page: number) => void)` |  |
| `onRetry` | `(() => void)` |  |
| `onRowOpen` | `((row: T) => void)` | Opens a row: click anywhere that is not a control, or Enter on a focused cell. |
| `onSelectAllMatching` | `(() => void)` | Called by "Select all N" in the selection banner. The app records that every match is selected. |
| `onSelectedRowIdsChange` | `((ids: string[]) => void)` |  |
| `onSortChange` | `((sort: DataTableSort[]) => void)` |  |
| `page` | `number` | 1-based page (controlled). |
| `pageSize` | `number` | Rows per page. Adds a Pagination footer. |
| `renderExpanded` | `((row: T) => ReactNode)` | Detail content of an expanded row. Adds a leading expand column. |
| `rowActions` | `((row: T) => DataTableRowAction[])` | Actions for a row: inline buttons and an overflow menu in a trailing column. |
| `selectable` | `boolean` | Adds a checkbox column. |
| `selectedRowIds` | `readonly string[]` | Selected row ids (controlled). |
| `showCaption` | `boolean` | Show the caption visually. Default false (it stays available to assistive technology). |
| `sort` | `readonly DataTableSort[]` | Sort state (controlled). |
| `stickyFirstColumn` | `boolean` | Keep the selection/expand columns and the first data column visible while scrolling sideways. |
| `stickyHeader` | `boolean` | Keep the header visible while the body scrolls. Set `maxHeight` too. |
| `totalRowCount` | `number` | Rows matching the current filters across all pages (server-driven). Enables "select all N". |

### `DataTableColumnChooser`

Popover to show, hide and reorder DataTable columns. Reordering uses buttons, so it works by keyboard and screen reader without drag and drop.

| Prop | Type | Description |
| --- | --- | --- |
| `columns` required | `readonly DataTableColumnChooserColumn[]` |  |
| `onVisibilityChange` required | `(visibility: Record<string, boolean>) => void` |  |
| `visibility` required | `Readonly<Record<string, boolean>>` | Visible columns by id; missing ids count as visible. |
| `className` | `string` |  |
| `messages` | `Partial<DataTableColumnChooserMessages>` |  |
| `onOrderChange` | `((order: string[]) => void)` |  |
| `onReset` | `(() => void)` | Adds a "Reset columns" button. |
| `order` | `readonly string[]` | Current order. With `onOrderChange`, each row gets move up/down buttons. |
| `size` | `"xs" \| "sm" \| "md"` | Trigger size. Default `sm`. |

### `SortButton`

Column sort toggle; its accessible name states the current direction.

| Prop | Type | Description |
| --- | --- | --- |
| `children` required | `import("/home/runner/work/monarch/monarch/node_modules/@types/react/index").ReactNode` |  |
| `direction` required | `SortDirection` |  |
| `onClick` required | `() => void` |  |

### `Table`

Data table primitives. Wrap in `<Card flush>` for a framed table. Pass a `<TableCaption>` or `aria-label` so the table has an accessible name.

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `density` | `"compact" \| "comfortable"` | Row height. `compact` for dense data, `comfortable` (default) for mixed content. |
| `id` | `string` |  |
| `maxHeight` | `string \| number` | Maximum height of the scrolling wrapper, e.g. `480` or `'60vh'`. Pair with `stickyHeader`. |
| `stickyFirstColumn` | `boolean` | Keep the first column visible while the table scrolls sideways. Cells can also opt in one by one with `data-pinned` (DataTable does this for its selection and expand columns). |
| `stickyHeader` | `boolean` | Keep the header visible while the body scrolls (the wrapper needs a height, see `maxHeight`). |

### `TableBody`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `TableCaption`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `TableCell`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |
| `numeric` | `boolean` |  |

### `TableHead`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |
| `numeric` | `boolean` | Right-align numbers. |
| `onSortChange` | `((next: "ascending" \| "descending") => void)` | Called when the sort button is pressed. Required when `sort` is set. |
| `sort` | `SortDirection` | Current sort of this column. Renders a SortButton and sets aria-sort. |

### `TableHeader`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |

### `TableRow`

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `id` | `string` |  |
| `interactive` | `boolean` | Pointer affordance for rows that open something on click. Keep a real link or button inside. |
| `selected` | `boolean` | Highlights the row (also sets aria-selected). |

### `TableSelectCell`

Narrow checkbox column for row selection.

| Prop | Type | Description |
| --- | --- | --- |
| `label` required | `string` | Accessible name, e.g. "Select ticket 42" or "Select all". |
| `asChild` | `boolean` |  |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `disabled` | `boolean` |  |
| `header` | `boolean` | Render as a header cell (for the select-all checkbox). |
| `id` | `string` |  |
| `invalid` | `boolean` |  |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `density` | `'comfortable'``'compact'` | Row height. Default `comfortable`. Exposed as `data-density` on the table. |
| `layout` | `'table'``'cards'``'auto'` | `table` (default) scrolls sideways when narrow; `cards` renders a stacked list; `auto` switches to cards below `cardBreakpoint` (640px of the table’s own width). Exposed as `data-view`. |
| `stickyHeader / stickyFirstColumn` | `'true'``'false'` | Keep the header row, and the selection/expand columns plus the first data column, visible while scrolling. Give `maxHeight` for a vertical scroll area. |
| `multiSort` | `'true'``'false'` | Shift+click or Shift+Enter on a header adds it to the sort; headers show their sort position. |
| `manualSorting / manualPagination` | `'true'``'false'` | Server-driven mode: rows arrive sorted and paged; DataTable only reports `onSortChange` and `onPageChange`. Pass `totalRowCount`. |
| `numeric (column)` | `'true'``'false'` | Right-aligns the header and cells with tabular figures (`data-numeric`). |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Loading | `aria-busy, data-busy` | Without rows the body shows a LoadingState skeleton; with rows they stay, a progress line runs above them and the table is `aria-busy`. "Loading rows" is announced. |
| Empty / filtered empty | – | An EmptyState fills the body. With `filtered` it explains that filters hide everything and offers `onClearFilters`. `emptyState` replaces it. |
| Error | – | Without rows an ErrorState with "Try again" (`onRetry`) replaces the body; with rows a compact one appears above them. |
| Sorted column | `aria-sort, data-direction` | The primary sorted column has `aria-sort`; the sort button’s name states the direction (and position in a multi-sort). |
| Selected row | `aria-selected, data-selected` | Selected rows are highlighted. The header checkbox is checked, unchecked or mixed for the current page. |
| All matching selected | – | With `totalRowCount` and `onSelectAllMatching`, selecting the whole page shows "Select all N"; `allMatchingSelected` shows "All N rows are selected" with "Clear selection". |
| Expanded row | `aria-expanded, data-expanded` | A detail row follows the row, linked by `aria-controls`. |

## Interaction behaviour

- A sortable header cycles ascending → descending → unsorted. With `multiSort`, Shift adds or updates a secondary sort.
- Row checkboxes select one row; Shift+click selects the range since the last clicked row. The header checkbox selects or clears every selectable row on the page.
- With `onRowOpen`, clicking a table row anywhere except a control (or while text is selected) opens it; in the card layout each card has an explicit “Open …” button.
- Row actions marked `inline` are visible buttons; the rest are in an overflow menu.
- Resizable columns are dragged by their handle; double-click resets to the default width.
- DataTableColumnChooser shows, hides and reorders columns with checkboxes and move buttons (no drag and drop), and can reset them.
- Client-side sorting and paging happen when `manualSorting` / `manualPagination` are off.

## Keyboard support

| Keys | Action |
| --- | --- |
| Tab | Without grid navigation: moves through sort buttons, resize handles, checkboxes and row controls. With grid navigation: enters and leaves the table (one tab stop). |
| Arrow keys | Grid navigation: move one cell (RTL aware). |
| Home / End | Grid navigation: first / last cell in the row. |
| Ctrl+Home / Ctrl+End | Grid navigation: first / last cell of the table. |
| Page Up / Page Down | Grid navigation: move five rows. |
| Enter | On a cell: opens the row (`onRowOpen`), or moves into a cell with several controls. On a sort button: sorts (Shift+Enter adds to a multi-sort). |
| Space | On a row cell: toggles its selection. On a checkbox: toggles it. |
| Escape | Leaves a cell with several controls, back to grid navigation. |
| Arrow Left / Right, Home / End | On a focused resize handle: narrower / wider by 16px (64px with Shift), minimum / maximum. In grid navigation use Alt+Arrow on the header cell. |

Grid navigation (WAI-ARIA grid pattern) is on by default when `onRowOpen` is set; turn it on or off with `keyboardNavigation`. A cell with one control focuses that control directly; text inputs inside cells keep their arrow keys.

## Accessibility

Role

table, or grid with keyboard navigation

Accessible name

caption

(required), rendered as a

<caption>

; visually hidden unless

showCaption

.

- The first visible column renders as row headers (`th scope="row"`) so every cell is announced with its row.
- `aria-sort` is set on one header at a time (the primary sort), as ARIA recommends.
- Utility columns (expand, select, actions) have visually hidden header text, so no header cell is empty.
- Hidden columns (visibility or `hideBelow`) are removed from the accessibility tree too; the card layout shows every visible column as a labelled field.
- The resize handle is a focusable `separator` with `aria-valuenow/min/max`.
- Loading is announced through a polite status region; selection counts are announced by BulkActionBar.
- Card layout is a labelled list; each card has a labelled checkbox and an "Open …" button, so it never relies on a clickable container.

## Responsive behaviour

- Default: the table scrolls sideways inside its wrapper; with `stickyFirstColumn` the row name stays visible at 320px.
- `hideBelow` on a column hides it while the table is narrower than that width (responsive column priority).
- `layout="auto"` switches to stacked cards under `cardBreakpoint`, measured on the table itself (works inside split panes); `layout="cards"` always uses them.
- Pagination switches to its compact variant in the card layout.

## Theme support

- Borders, hover, selected, pinned-column and banner backgrounds use Monarch semantic tokens, so light, dark and high-contrast themes apply.
- The loading line stops animating with `prefers-reduced-motion: reduce`; expand and resize transitions are removed.
- Wrap in `<Card flush>` for a framed table.

## Do and don't

### Do

- Give every table a `caption` and rows a `getRowLabel`, so controls are named after the row.
- Use `manualSorting` and `manualPagination` when the server holds more rows than the page.
- Pair selection with BulkActionBar, and filters with FilterBar and `filtered` for the empty state.
- Mark numeric columns `numeric` and keep the record name in the first column.

### Don't

- Do not hide important columns with `hideBelow` without offering the card layout or a detail view.
- Do not put several unrelated controls in one cell; give each its own column or a row action.
- Do not use a table for page layout.

## Related components

- [BulkActionBar](https://monarchui.dev/components/bulk-action-bar/): Actions on the selected rows.
- [FilterBar](https://monarchui.dev/components/filter-bar/): Search and filters above the table.
- [SavedViews](https://monarchui.dev/components/saved-views/): Named combinations of filters, sort and columns.
- [Pagination](https://monarchui.dev/components/pagination/): The pager DataTable renders with `pageSize`.
- [LoadMore](https://monarchui.dev/components/load-more/): Cursor pagination; pass it as `footer`.
- [EmptyState](https://monarchui.dev/components/empty-state/): The empty body.
- [DescriptionList](https://monarchui.dev/components/description-list/): Details of a single record.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [Checkbox](https://monarchui.dev/components/checkbox/), [DropdownMenu](https://monarchui.dev/components/dropdown-menu/), [Popover](https://monarchui.dev/components/popover/), [EmptyState](https://monarchui.dev/components/empty-state/), [ErrorState](https://monarchui.dev/components/error-state/), [LoadingState](https://monarchui.dev/components/loading-state/), [Pagination](https://monarchui.dev/components/pagination/) |

No virtualisation: no product evidence needs more than a page of rows on screen.

## Testing coverage

- sortRows: stable, numeric-aware, empty last, multi-key; nextSort cycle and additive mode.
- Caption, row headers and numeric cells; client sort with aria-sort on the primary column; multi-sort with Shift and priority in the name.
- Server-driven mode reports sort and page without reordering; client pagination.
- Row selection, select all on page (mixed state), Shift range, isRowSelectable, controlled selection, "select all matching" banner.
- Column visibility, order and hideBelow; column chooser hides, reorders by button (focus kept) and resets.
- Loading, error (retry), empty and filtered-empty states; rows stay while reloading.
- Row actions with row-context names (inline and menu); expandable rows with aria-expanded/aria-controls.
- Row click opens except on controls; grid keyboard: one tab stop, arrows, Home/End, Ctrl+End, Enter opens, Space selects.
- Keyboard column resizing; pinned columns; card layout with labelled fields; server rendering.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Gallery visual baselines (every example) and cross-browser axe on #/components/data-table. |

Tests: `packages/react/src/components/data-grid/data-table`

## All example code

### Sorting, selection, row actions and grid keyboard

```tsx
import { useState } from 'react';
import { Badge } from '@rookcom/monarch-react';
import { DataTable, type DataTableColumn } from '@rookcom/monarch-react/data';

interface Invoice {
  id: string;
  customer: string;
  status: 'Paid' | 'Due' | 'Overdue';
  amount: number;
  issued: string;
  owner: string;
}

const INVOICES: Invoice[] = [
  {
    id: 'INV-1042',
    customer: 'Northfield Traders',
    status: 'Paid',
    amount: 1240,
    issued: '2026-09-02',
    owner: 'Ana',
  },
  {
    id: 'INV-1043',
    customer: 'Cobalt Ltd',
    status: 'Due',
    amount: 860,
    issued: '2026-09-11',
    owner: 'Ben',
  },
  {
    id: 'INV-1044',
    customer: 'Fernway Inc',
    status: 'Overdue',
    amount: 4300,
    issued: '2026-08-21',
    owner: 'Chen',
  },
  {
    id: 'INV-1045',
    customer: 'Tidewater Toys',
    status: 'Due',
    amount: 395,
    issued: '2026-09-18',
    owner: 'Ana',
  },
  {
    id: 'INV-1046',
    customer: 'Westbridge Imports',
    status: 'Paid',
    amount: 2150,
    issued: '2026-09-20',
    owner: 'Dev',
  },
  {
    id: 'INV-1047',
    customer: 'Lumen',
    status: 'Due',
    amount: 1720,
    issued: '2026-09-24',
    owner: 'Ben',
  },
];

const TONE = { Paid: 'success', Due: 'neutral', Overdue: 'danger' } as const;

const COLUMNS: DataTableColumn<Invoice>[] = [
  { id: 'customer', header: 'Customer', sortable: true },
  {
    id: 'status',
    header: 'Status',
    sortable: true,
    cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge>,
  },
  { id: 'issued', header: 'Issued', sortable: true, hideBelow: 520 },
  { id: 'owner', header: 'Owner', sortable: true, defaultHidden: false },
  {
    id: 'amount',
    header: 'Amount',
    numeric: true,
    sortable: true,
    cell: (r) => `$${r.amount.toLocaleString('en-US')}`,
  },
];

const shared = {
  rows: INVOICES,
  getRowId: (r: Invoice) => r.id,
  getRowLabel: (r: Invoice) => `${r.id} for ${r.customer}`,
};

function SortAndSelect() {
  const [selected, setSelected] = useState<string[]>(['INV-1044']);
  const [opened, setOpened] = useState<string>();
  return (
    <div style={{ display: 'grid', gap: 8, width: '100%' }}>
      <DataTable
        {...shared}
        columns={COLUMNS}
        caption="Invoices"
        defaultSort={[{ id: 'issued', direction: 'descending' }]}
        multiSort
        selectable
        selectedRowIds={selected}
        onSelectedRowIdsChange={setSelected}
        stickyFirstColumn
        onRowOpen={(r) => setOpened(r.id)}
        rowActions={(r) => [
          { id: 'send', label: 'Send reminder', onSelect: () => setOpened(`reminder ${r.id}`) },
          { id: 'void', label: 'Void invoice', tone: 'danger', onSelect: () => {} },
        ]}
      />
      <p style={{ margin: 0, fontSize: 13 }}>
        {selected.length} selected{opened ? ` · opened ${opened}` : ''}
      </p>
    </div>
  );
}

<SortAndSelect />
```

### Server-driven sorting and pagination

```tsx
import { useState } from 'react';
import { Badge } from '@rookcom/monarch-react';
import { DataTable, type DataTableColumn, type DataTableSort } from '@rookcom/monarch-react/data';

interface Invoice {
  id: string;
  customer: string;
  status: 'Paid' | 'Due' | 'Overdue';
  amount: number;
  issued: string;
  owner: string;
}

const INVOICES: Invoice[] = [
  {
    id: 'INV-1042',
    customer: 'Northfield Traders',
    status: 'Paid',
    amount: 1240,
    issued: '2026-09-02',
    owner: 'Ana',
  },
  {
    id: 'INV-1043',
    customer: 'Cobalt Ltd',
    status: 'Due',
    amount: 860,
    issued: '2026-09-11',
    owner: 'Ben',
  },
  {
    id: 'INV-1044',
    customer: 'Fernway Inc',
    status: 'Overdue',
    amount: 4300,
    issued: '2026-08-21',
    owner: 'Chen',
  },
  {
    id: 'INV-1045',
    customer: 'Tidewater Toys',
    status: 'Due',
    amount: 395,
    issued: '2026-09-18',
    owner: 'Ana',
  },
  {
    id: 'INV-1046',
    customer: 'Westbridge Imports',
    status: 'Paid',
    amount: 2150,
    issued: '2026-09-20',
    owner: 'Dev',
  },
  {
    id: 'INV-1047',
    customer: 'Lumen',
    status: 'Due',
    amount: 1720,
    issued: '2026-09-24',
    owner: 'Ben',
  },
];

const TONE = { Paid: 'success', Due: 'neutral', Overdue: 'danger' } as const;

const COLUMNS: DataTableColumn<Invoice>[] = [
  { id: 'customer', header: 'Customer', sortable: true },
  {
    id: 'status',
    header: 'Status',
    sortable: true,
    cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge>,
  },
  { id: 'issued', header: 'Issued', sortable: true, hideBelow: 520 },
  { id: 'owner', header: 'Owner', sortable: true, defaultHidden: false },
  {
    id: 'amount',
    header: 'Amount',
    numeric: true,
    sortable: true,
    cell: (r) => `$${r.amount.toLocaleString('en-US')}`,
  },
];

function ServerDriven() {
  // Stand-in for a server: sorts and pages a larger list on request.
  const all = Array.from({ length: 23 }, (_, i) => ({
    ...(INVOICES[i % INVOICES.length] as Invoice),
    id: `INV-${2000 + i}`,
    amount: 100 + ((i * 337) % 4000),
  }));
  const [sort, setSort] = useState<DataTableSort[]>([{ id: 'amount', direction: 'descending' }]);
  const [page, setPage] = useState(1);
  const s = sort[0];
  const ordered = s
    ? all.slice().sort((a, b) => {
        const k = s.id as keyof Invoice;
        const r = String(a[k]).localeCompare(String(b[k]), undefined, { numeric: true });
        return s.direction === 'ascending' ? r : -r;
      })
    : all;
  const rows = ordered.slice((page - 1) * 5, page * 5);
  return (
    <DataTable
      columns={COLUMNS}
      rows={rows}
      getRowId={(r) => r.id}
      caption="Invoices (server sorted)"
      sort={sort}
      onSortChange={(next) => {
        setSort(next);
        setPage(1);
      }}
      manualSorting
      manualPagination
      pageSize={5}
      page={page}
      onPageChange={setPage}
      totalRowCount={all.length}
      density="compact"
    />
  );
}

<ServerDriven />
```

### Column chooser, resizing and expandable rows

```tsx
import { Badge } from '@rookcom/monarch-react';
import { DataTable, DataTableColumnChooser, useDataTableColumns, type DataTableColumn } from '@rookcom/monarch-react/data';

interface Invoice {
  id: string;
  customer: string;
  status: 'Paid' | 'Due' | 'Overdue';
  amount: number;
  issued: string;
  owner: string;
}

const INVOICES: Invoice[] = [
  {
    id: 'INV-1042',
    customer: 'Northfield Traders',
    status: 'Paid',
    amount: 1240,
    issued: '2026-09-02',
    owner: 'Ana',
  },
  {
    id: 'INV-1043',
    customer: 'Cobalt Ltd',
    status: 'Due',
    amount: 860,
    issued: '2026-09-11',
    owner: 'Ben',
  },
  {
    id: 'INV-1044',
    customer: 'Fernway Inc',
    status: 'Overdue',
    amount: 4300,
    issued: '2026-08-21',
    owner: 'Chen',
  },
  {
    id: 'INV-1045',
    customer: 'Tidewater Toys',
    status: 'Due',
    amount: 395,
    issued: '2026-09-18',
    owner: 'Ana',
  },
  {
    id: 'INV-1046',
    customer: 'Westbridge Imports',
    status: 'Paid',
    amount: 2150,
    issued: '2026-09-20',
    owner: 'Dev',
  },
  {
    id: 'INV-1047',
    customer: 'Lumen',
    status: 'Due',
    amount: 1720,
    issued: '2026-09-24',
    owner: 'Ben',
  },
];

const TONE = { Paid: 'success', Due: 'neutral', Overdue: 'danger' } as const;

const COLUMNS: DataTableColumn<Invoice>[] = [
  { id: 'customer', header: 'Customer', sortable: true },
  {
    id: 'status',
    header: 'Status',
    sortable: true,
    cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge>,
  },
  { id: 'issued', header: 'Issued', sortable: true, hideBelow: 520 },
  { id: 'owner', header: 'Owner', sortable: true, defaultHidden: false },
  {
    id: 'amount',
    header: 'Amount',
    numeric: true,
    sortable: true,
    cell: (r) => `$${r.amount.toLocaleString('en-US')}`,
  },
];

const shared = {
  rows: INVOICES,
  getRowId: (r: Invoice) => r.id,
  getRowLabel: (r: Invoice) => `${r.id} for ${r.customer}`,
};

function ColumnsAndDetails() {
  const columns: DataTableColumn<Invoice>[] = COLUMNS.map((c) =>
    c.id === 'customer' ? { ...c, resizable: true, width: 220, hideable: false } : c,
  );
  const cols = useDataTableColumns(columns);
  return (
    <div style={{ display: 'grid', gap: 8, width: '100%' }}>
      <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
        <DataTableColumnChooser {...cols.chooserProps} />
      </div>
      <DataTable
        {...shared}
        rows={INVOICES.slice(0, 3)}
        columns={columns}
        {...cols.tableProps}
        caption="Invoices with details"
        defaultExpandedRowIds={['INV-1043']}
        renderExpanded={(r) => (
          <p style={{ margin: 0 }}>
            {r.customer} was invoiced on {r.issued}. Owner: {r.owner}.
          </p>
        )}
      />
    </div>
  );
}

<ColumnsAndDetails />
```

### Loading, filtered empty and error states

```tsx
import { Badge } from '@rookcom/monarch-react';
import { DataTable, type DataTableColumn } from '@rookcom/monarch-react/data';

interface Invoice {
  id: string;
  customer: string;
  status: 'Paid' | 'Due' | 'Overdue';
  amount: number;
  issued: string;
  owner: string;
}

const INVOICES: Invoice[] = [
  {
    id: 'INV-1042',
    customer: 'Northfield Traders',
    status: 'Paid',
    amount: 1240,
    issued: '2026-09-02',
    owner: 'Ana',
  },
  {
    id: 'INV-1043',
    customer: 'Cobalt Ltd',
    status: 'Due',
    amount: 860,
    issued: '2026-09-11',
    owner: 'Ben',
  },
  {
    id: 'INV-1044',
    customer: 'Fernway Inc',
    status: 'Overdue',
    amount: 4300,
    issued: '2026-08-21',
    owner: 'Chen',
  },
  {
    id: 'INV-1045',
    customer: 'Tidewater Toys',
    status: 'Due',
    amount: 395,
    issued: '2026-09-18',
    owner: 'Ana',
  },
  {
    id: 'INV-1046',
    customer: 'Westbridge Imports',
    status: 'Paid',
    amount: 2150,
    issued: '2026-09-20',
    owner: 'Dev',
  },
  {
    id: 'INV-1047',
    customer: 'Lumen',
    status: 'Due',
    amount: 1720,
    issued: '2026-09-24',
    owner: 'Ben',
  },
];

const TONE = { Paid: 'success', Due: 'neutral', Overdue: 'danger' } as const;

const COLUMNS: DataTableColumn<Invoice>[] = [
  { id: 'customer', header: 'Customer', sortable: true },
  {
    id: 'status',
    header: 'Status',
    sortable: true,
    cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge>,
  },
  { id: 'issued', header: 'Issued', sortable: true, hideBelow: 520 },
  { id: 'owner', header: 'Owner', sortable: true, defaultHidden: false },
  {
    id: 'amount',
    header: 'Amount',
    numeric: true,
    sortable: true,
    cell: (r) => `$${r.amount.toLocaleString('en-US')}`,
  },
];

const shared = {
  rows: INVOICES,
  getRowId: (r: Invoice) => r.id,
  getRowLabel: (r: Invoice) => `${r.id} for ${r.customer}`,
};

function States() {
  return (
    <div style={{ display: 'grid', gap: 24, width: '100%' }}>
      <DataTable {...shared} rows={[]} columns={COLUMNS} caption="Loading invoices" loading />
      <DataTable
        {...shared}
        rows={[]}
        columns={COLUMNS}
        caption="Filtered invoices"
        filtered
        onClearFilters={() => {}}
      />
      <DataTable
        {...shared}
        rows={[]}
        columns={COLUMNS}
        caption="Failed invoices"
        error="The server did not respond."
        onRetry={() => {}}
      />
    </div>
  );
}

<States />
```

### Card layout for narrow screens

```tsx
import { Badge } from '@rookcom/monarch-react';
import { DataTable, type DataTableColumn } from '@rookcom/monarch-react/data';

interface Invoice {
  id: string;
  customer: string;
  status: 'Paid' | 'Due' | 'Overdue';
  amount: number;
  issued: string;
  owner: string;
}

const INVOICES: Invoice[] = [
  {
    id: 'INV-1042',
    customer: 'Northfield Traders',
    status: 'Paid',
    amount: 1240,
    issued: '2026-09-02',
    owner: 'Ana',
  },
  {
    id: 'INV-1043',
    customer: 'Cobalt Ltd',
    status: 'Due',
    amount: 860,
    issued: '2026-09-11',
    owner: 'Ben',
  },
  {
    id: 'INV-1044',
    customer: 'Fernway Inc',
    status: 'Overdue',
    amount: 4300,
    issued: '2026-08-21',
    owner: 'Chen',
  },
  {
    id: 'INV-1045',
    customer: 'Tidewater Toys',
    status: 'Due',
    amount: 395,
    issued: '2026-09-18',
    owner: 'Ana',
  },
  {
    id: 'INV-1046',
    customer: 'Westbridge Imports',
    status: 'Paid',
    amount: 2150,
    issued: '2026-09-20',
    owner: 'Dev',
  },
  {
    id: 'INV-1047',
    customer: 'Lumen',
    status: 'Due',
    amount: 1720,
    issued: '2026-09-24',
    owner: 'Ben',
  },
];

const TONE = { Paid: 'success', Due: 'neutral', Overdue: 'danger' } as const;

const COLUMNS: DataTableColumn<Invoice>[] = [
  { id: 'customer', header: 'Customer', sortable: true },
  {
    id: 'status',
    header: 'Status',
    sortable: true,
    cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge>,
  },
  { id: 'issued', header: 'Issued', sortable: true, hideBelow: 520 },
  { id: 'owner', header: 'Owner', sortable: true, defaultHidden: false },
  {
    id: 'amount',
    header: 'Amount',
    numeric: true,
    sortable: true,
    cell: (r) => `$${r.amount.toLocaleString('en-US')}`,
  },
];

const shared = {
  rows: INVOICES,
  getRowId: (r: Invoice) => r.id,
  getRowLabel: (r: Invoice) => `${r.id} for ${r.customer}`,
};

function Cards() {
  return (
    <div style={{ width: '100%', maxWidth: 360 }}>
      <DataTable
        {...shared}
        rows={INVOICES.slice(0, 3)}
        columns={COLUMNS}
        caption="Invoices"
        layout="cards"
        selectable
        defaultSelectedRowIds={['INV-1042']}
        onRowOpen={() => {}}
      />
    </div>
  );
}

<Cards />
```

### Table parts (custom tables)

```tsx
import { useState } from 'react';
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, TableSelectCell } from '@rookcom/monarch-react';

function Primitives() {
  const [sort, setSort] = useState<'ascending' | 'descending'>('ascending');
  return (
    <Table>
      <TableCaption>Open tickets</TableCaption>
      <TableHeader>
        <TableRow>
          <TableSelectCell header label="Select all" />
          <TableHead sort={sort} onSortChange={setSort}>
            Title
          </TableHead>
          <TableHead numeric>Age</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow selected>
          <TableSelectCell label="Select ticket 1" checked />
          <TableCell>Login link expired</TableCell>
          <TableCell numeric>3d</TableCell>
        </TableRow>
        <TableRow>
          <TableSelectCell label="Select ticket 2" />
          <TableCell>Export stuck at 90%</TableCell>
          <TableCell numeric>1d</TableCell>
        </TableRow>
      </TableBody>
    </Table>
  );
}

<Primitives />
```
