Data Table
A table for records people scan, compare, sort and select, with loading and empty states.
Preview
| Reference | Date | Payee | Method | Status | Amount |
|---|---|---|---|---|---|
| PAY-4821 | Sep 26 | City Pharmacy | Card | Paid | $12.00 |
| PAY-4820 | Sep 26 | Metro Transit | Bank transfer | Refunded | $91.19 |
| PAY-4819 | Sep 25 | Lakeview Dental | Wallet | Failed | $170.38 |
| PAY-4818 | Sep 25 | Corner Grocery | Card | Paid | $249.57 |
| PAY-4817 | Sep 24 | Northside Fuel | Bank transfer | Pending | $328.76 |
| PAY-4816 | Sep 24 | Riverside Clinic | Wallet | Paid | $407.95 |
| PAY-4815 | Sep 23 | Hilltop Books | Card | Paid | $487.14 |
| PAY-4814 | Sep 23 | City Pharmacy | Bank transfer | Refunded | $86.33 |
| PAY-4813 | Sep 22 | Metro Transit | Wallet | Failed | $165.52 |
| PAY-4812 | Sep 22 | Lakeview Dental | Card | Paid | $244.71 |
Installation
pnpm add @syntara/react @syntara/tokensimport { DataTable, useSortedRows, DataTableToolbar, DataTablePagination } from '@syntara/react';Usage
import { useState } from 'react';
import { DataTable, useSortedRows, type DataTableColumn, type DataTableSortDescriptor } from '@syntara/react';
type Payment = { id: string; payee: string; amount: number };
const columns: DataTableColumn<Payment>[] = [
{ id: 'id', header: 'Reference', isRowHeader: true, cell: (r) => r.id },
{ id: 'payee', header: 'Payee', allowsSorting: true, cell: (r) => r.payee },
{ id: 'amount', header: 'Amount', align: 'end', allowsSorting: true, cell: (r) => r.amount.toFixed(2) },
];
const accessors = { payee: (r: Payment) => r.payee, amount: (r: Payment) => r.amount };
export function Payments({ rows }: { rows: Payment[] }) {
const [sort, setSort] = useState<DataTableSortDescriptor>();
const sorted = useSortedRows(rows, sort, accessors);
return (
<DataTable aria-label="Payments" columns={columns} rows={sorted} getRowId={(r) => r.id}
sortDescriptor={sort} onSortChange={setSort} selectionMode="multiple" />
);
}Examples
Sorting and sticky header
Every column sorts; the header stays put while the body scrolls inside maxBlockSize.
| Order | Customer | Placed | Items | Total |
|---|---|---|---|---|
| ORD-7339 | Daniel Okafor | Aug 20, 2:00 PM | 6 | $306.63 |
| ORD-7334 | Asha Menon | Aug 25, 9:00 AM | 7 | $299.28 |
| ORD-7329 | Nadia Karim | Aug 30, 12:00 PM | 8 | $291.93 |
| ORD-7324 | Lucas Martin | Aug 5, 3:00 PM | 9 | $284.58 |
| ORD-7319 | Kiran Rao | Aug 10, 10:00 AM | 1 | $277.23 |
| ORD-7314 | Sofia Rossi | Aug 15, 1:00 PM | 2 | $269.88 |
| ORD-7338 | Sofia Rossi | Aug 9, 1:00 PM | 8 | $241.16 |
| ORD-7333 | Omar Haddad | Aug 14, 4:00 PM | 9 | $233.81 |
| ORD-7328 | Mei Lin | Aug 19, 11:00 AM | 1 | $226.46 |
| ORD-7323 | Daniel Okafor | Aug 24, 2:00 PM | 2 | $219.11 |
| ORD-7318 | Asha Menon | Aug 29, 9:00 AM | 3 | $211.76 |
| ORD-7313 | Nadia Karim | Aug 4, 12:00 PM | 4 | $204.41 |
| ORD-7337 | Nadia Karim | Aug 28, 12:00 PM | 1 | $175.69 |
| ORD-7332 | Lucas Martin | Aug 3, 3:00 PM | 2 | $168.34 |
| ORD-7327 | Kiran Rao | Aug 8, 10:00 AM | 3 | $160.99 |
| ORD-7322 | Sofia Rossi | Aug 13, 1:00 PM | 4 | $153.64 |
| ORD-7317 | Omar Haddad | Aug 18, 4:00 PM | 5 | $146.29 |
| ORD-7312 | Mei Lin | Aug 23, 11:00 AM | 6 | $138.94 |
| ORD-7336 | Mei Lin | Aug 17, 11:00 AM | 3 | $110.22 |
| ORD-7331 | Daniel Okafor | Aug 22, 2:00 PM | 4 | $102.87 |
| ORD-7326 | Asha Menon | Aug 27, 9:00 AM | 5 | $95.52 |
| ORD-7321 | Nadia Karim | Aug 2, 12:00 PM | 6 | $88.17 |
| ORD-7316 | Lucas Martin | Aug 7, 3:00 PM | 7 | $80.82 |
| ORD-7311 | Kiran Rao | Aug 12, 10:00 AM | 8 | $73.47 |
| ORD-7335 | Kiran Rao | Aug 6, 10:00 AM | 5 | $44.75 |
| ORD-7330 | Sofia Rossi | Aug 11, 1:00 PM | 6 | $37.40 |
| ORD-7325 | Omar Haddad | Aug 16, 4:00 PM | 7 | $30.05 |
| ORD-7320 | Mei Lin | Aug 21, 11:00 AM | 8 | $22.70 |
| ORD-7315 | Daniel Okafor | Aug 26, 2:00 PM | 9 | $15.35 |
| ORD-7310 | Asha Menon | Aug 1, 9:00 AM | 1 | $8.00 |
Selection with a toolbar
Checkbox selection, search and a bulk action that reflects the selection.
| Claim | Member | Type | Status | Amount | |
|---|---|---|---|---|---|
| CLM-20480 | Asha Menon | Outpatient | In review | $25.00 | |
| CLM-20481 | Sofia Rossi | Hospital stay | Needs info | $60.71 | |
| CLM-20482 | Daniel Okafor | Pharmacy | In review | $96.42 | |
| CLM-20483 | Kiran Rao | Vision | Approved | $132.13 | |
| CLM-20484 | Mei Lin | Dental | In review | $167.84 | |
| CLM-20485 | Lucas Martin | Outpatient | Needs info | $203.55 | |
| CLM-20486 | Omar Haddad | Hospital stay | In review | $239.26 | |
| CLM-20487 | Asha Menon | Pharmacy | Approved | $274.97 | |
| CLM-20488 | Sofia Rossi | Vision | In review | $310.68 | |
| CLM-20489 | Daniel Okafor | Dental | Needs info | $346.39 | |
| CLM-20490 | Kiran Rao | Outpatient | In review | $382.10 | |
| CLM-20491 | Mei Lin | Hospital stay | Approved | $417.81 | |
| CLM-20492 | Lucas Martin | Pharmacy | In review | $453.52 | |
| CLM-20493 | Omar Haddad | Vision | Needs info | $489.23 | |
| CLM-20494 | Asha Menon | Dental | In review | $524.94 | |
| CLM-20495 | Sofia Rossi | Outpatient | Approved | $560.65 | |
| CLM-20496 | Daniel Okafor | Hospital stay | In review | $596.36 | |
| CLM-20497 | Kiran Rao | Pharmacy | Needs info | $632.07 | |
| CLM-20498 | Mei Lin | Vision | In review | $667.78 | |
| CLM-20499 | Lucas Martin | Dental | Approved | $703.49 | |
| CLM-20500 | Omar Haddad | Outpatient | In review | $739.20 | |
| CLM-20501 | Asha Menon | Hospital stay | Needs info | $774.91 | |
| CLM-20502 | Sofia Rossi | Pharmacy | In review | $810.62 | |
| CLM-20503 | Daniel Okafor | Vision | Approved | $846.33 |
Empty
An EmptyState in place of rows.
| Invoice | Issued | Amount |
|---|---|---|
No invoices yetInvoices appear here after your first billing cycle closes. | ||
Loading
Skeleton rows while data loads; the grid is aria-busy.
| Reference | Recipient | Date | Status | Amount | |
|---|---|---|---|---|---|
Accessibility
| Keys | Action |
|---|---|
| Tab | Moves focus into the table (to the last focused cell or row), then out of it. |
| ↑or↓ | Moves between rows, including up into the header row. |
| ←or→ | Moves between cells and column headers (mirrored in right-to-left). |
| HomeorEnd, Page UporPage Down | Moves to the first / last cell in the row, or by a page of rows. |
| EnterorSpace on a sortable header | Sorts by that column; press again to reverse. |
| Space | Toggles selection of the focused row (selectionMode single or multiple). |
| Enter | Performs onRowAction on the focused row. |
| Ctrl/⌘A | Selects all rows (selectionMode multiple). |
- Renders role="grid" (a native <table>) named by aria-label or aria-labelledby; sortable headers expose aria-sort.
- Mark the identifying column isRowHeader so screen readers announce it when moving between rows.
- Row and select-all checkboxes are named by React Aria ("Select", "Select All"); the select-all box shows the indeterminate state.
- Sort direction is shown with an arrow icon and aria-sort, not colour; unsorted sortable columns show a neutral up/down icon.
- While loading, the grid is aria-busy="true"; skeleton rows are disabled, their placeholders are hidden, and each row header reads loadingLabel ("Loading") so no header is empty. Column headers stay visible.
- The table scrolls sideways inside its own frame on narrow screens; the page never scrolls horizontally.
- Selected rows use surface.selected, and hover does not darken them. Inside selected rows text.subtle is re-pointed to text.default so secondary text keeps AA on the tint.
- DataTablePagination is a labelled group, not a landmark, by default, so several tables on one page never produce duplicate navigation landmarks.
Guidelines
Do
- End-align numbers and amounts (align: 'end') so digits line up.
- Keep one column that names the row (isRowHeader) — usually a reference or name.
- Show the result count and page with DataTablePagination; reset to page 1 when filters change.
- Match loadingRowCount to the page size.
Don’t
- Don't put more than one or two interactive controls in a row — use onRowAction to open detail.
- Don't encode status with colour alone — use a Badge with a word.
- Don't use a DataTable for page layout or for fewer than about three rows of simple data — use a list.
API reference
DataTable
aria-labelRequiredstringNames the table. Or pass aria-labelledby pointing at a visible heading.
columnsRequiredDataTableColumn<T>[]{ id, header, cell: (row) => ReactNode, isRowHeader?, allowsSorting?, align?: 'start' | 'end' | 'center', width?, minWidth?, textValue? }. Mark the column that names each row isRowHeader. Without an isRowHeader column, the first column becomes the row header.
rowsRequiredreadonly T[]The rows to show, already sorted and paged.
getRowIdRequired(row: T) => stringA stable, unique id per row. Used for selection keys and onRowAction.
selectionModenonedefaultsinglemultiple
Adds a checkbox column; multiple also adds select-all in the header.
selectedKeys'all' | Iterable<Key>Selected row ids (controlled).
defaultSelectedKeys'all' | Iterable<Key>Initially selected row ids (uncontrolled).
onSelectionChange(keys: DataTableSelection) => voidCalled with 'all' or a Set of row ids.
disabledKeysIterable<Key>Row ids that can't be selected or actioned.
sortDescriptorDataTableSortDescriptor{ column, direction } of the current sort (controlled). Sort rows yourself — see useSortedRows.
onSortChange(descriptor: DataTableSortDescriptor) => voidCalled when a sortable header is pressed.
onRowAction(id: string) => voidCalled when a row is activated (click or Enter), e.g. to open its detail page.
isLoadingbooleanShows skeleton rows and sets aria-busy on the grid.
Default
falseloadingRowCountnumberSkeleton rows while loading; match your page size to avoid a jump.
Default
5loadingLabelstringVisually hidden row-header text of each skeleton row, so rows are never announced with empty headers.
Default
'Loading'emptyStateReactNodeShown when rows is empty. Pass <EmptyState size="sm" …/> for a richer message.
Default
'No results.'densitycomfortablecompact
Overrides the theme density for this table (row 48/36px).
stickyHeaderbooleanKeeps the header visible while the body scrolls inside maxBlockSize.
Default
truemaxBlockSizenumber | stringMaximum height of the table's own scroll container.
classNamestringClass for the outer scroll container.
useSortedRows
rows, sortDescriptor, accessors(rows: readonly T[], sort: DataTableSortDescriptor | undefined, accessors: Record<string, (row: T) => string | number | bigint | boolean | Date | null | undefined>) => T[]Client-side sort. Locale-aware numeric string collation, dates and numbers by value, empty values last, stable ties. Define accessors outside the component.
DataTableToolbar
childrenReactNodeSearch, filters and actions. First child sits at the start, last at the end; wraps when narrow.
DataTablePagination
pageRequirednumberCurrent page, 1-based.
pageSizeRequirednumberRows per page.
totalCountRequirednumberTotal rows across all pages.
onPageChange(page: number) => voidCalled with the new page.
formatSummary(range: { start: number; end: number; total: number }) => ReactNodeBuilds the range text (a polite live region).
Default
"Showing 1–10 of 48"landmarkbooleanRender the pager as a <nav> landmark. Off by default: table paging belongs to the table, so it is a labelled group and several tables can share a page. Turn on with a unique label.
Default
falselabelstringAccessible name of the pager, e.g. "Payments pages".
Default
'Pagination'
Tokens
The semantic tokens this component reads, grouped by what they control. Swatches show this site’s theme; change a tenant’s brand and the component follows with no code change.
- Colour9
surface.defaultborder.defaulttext.defaultfocus.ringtext.subtleborder.subtlesurface.selectedaction.primary.bgtext.disabled- Type6
font.size.mdline-height.snugfont.weight.mediumfont.size.smfont.tracking.mdfont.tracking.sm- Space and size12
table-row-heightcontrol-heightspace.8space.1space.12space.10space.2space.3space.4space.6space.16card-inset- Shape1
radius.container- Depth4
shadow.raisedglass.bgglass.blurhairline- Motion5
motion.duration.fastmotion.easingmotion.duration.normalmotion.duration.springmotion.spring- Other2
sheenrim