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.
Sorting, selection, row actions and grid keyboard
| Actions | ||||||
|---|---|---|---|---|---|---|
| Lumen | Due | 2026-09-24 | Ben | $1,720 | ||
| Westbridge Imports | Paid | 2026-09-20 | Dev | $2,150 | ||
| Tidewater Toys | Due | 2026-09-18 | Ana | $395 | ||
| Cobalt Ltd | Due | 2026-09-11 | Ben | $860 | ||
| Northfield Traders | Paid | 2026-09-02 | Ana | $1,240 | ||
| Fernway Inc | Overdue | 2026-08-21 | Chen | $4,300 |
1 selected
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 />Usage examples
Server-driven sorting and pagination
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
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
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
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)
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 />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"), andaria-sorton 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
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
inlineare 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/manualPaginationare 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 unlessshowCaption.
- The first visible column renders as row headers (
th scope="row") so every cell is announced with its row. aria-sortis 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
separatorwitharia-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
stickyFirstColumnthe row name stays visible at 320px. hideBelowon a column hides it while the table is narrower than that width (responsive column priority).layout="auto"switches to stacked cards undercardBreakpoint, 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
captionand rows agetRowLabel, so controls are named after the row. - Use
manualSortingandmanualPaginationwhen the server holds more rows than the page. - Pair selection with BulkActionBar, and filters with FilterBar and
filteredfor the empty state. - Mark numeric columns
numericand keep the record name in the first column.
Don't
- Do not hide important columns with
hideBelowwithout 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: Actions on the selected rows.
- FilterBar: Search and filters above the table.
- SavedViews: Named combinations of filters, sort and columns.
- Pagination: The pager DataTable renders with
pageSize. - LoadMore: Cursor pagination; pass it as
footer. - EmptyState: The empty body.
- DescriptionList: Details of a single record.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | Button, Checkbox, DropdownMenu, Popover, EmptyState, ErrorState, LoadingState, 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