Skip to content

Data Table

A table for records people scan, compare, sort and select, with loading and empty states.

Preview

ReferenceDatePayeeMethodStatusAmount
PAY-4821Sep 26City PharmacyCardPaid$12.00
PAY-4820Sep 26Metro TransitBank transferRefunded$91.19
PAY-4819Sep 25Lakeview DentalWalletFailed$170.38
PAY-4818Sep 25Corner GroceryCardPaid$249.57
PAY-4817Sep 24Northside FuelBank transferPending$328.76
PAY-4816Sep 24Riverside ClinicWalletPaid$407.95
PAY-4815Sep 23Hilltop BooksCardPaid$487.14
PAY-4814Sep 23City PharmacyBank transferRefunded$86.33
PAY-4813Sep 22Metro TransitWalletFailed$165.52
PAY-4812Sep 22Lakeview DentalCardPaid$244.71

Installation

pnpm add @syntara/react @syntara/tokens
import { 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.

OrderCustomerPlacedItemsTotal
ORD-7339Daniel OkaforAug 20, 2:00 PM6$306.63
ORD-7334Asha MenonAug 25, 9:00 AM7$299.28
ORD-7329Nadia KarimAug 30, 12:00 PM8$291.93
ORD-7324Lucas MartinAug 5, 3:00 PM9$284.58
ORD-7319Kiran RaoAug 10, 10:00 AM1$277.23
ORD-7314Sofia RossiAug 15, 1:00 PM2$269.88
ORD-7338Sofia RossiAug 9, 1:00 PM8$241.16
ORD-7333Omar HaddadAug 14, 4:00 PM9$233.81
ORD-7328Mei LinAug 19, 11:00 AM1$226.46
ORD-7323Daniel OkaforAug 24, 2:00 PM2$219.11
ORD-7318Asha MenonAug 29, 9:00 AM3$211.76
ORD-7313Nadia KarimAug 4, 12:00 PM4$204.41
ORD-7337Nadia KarimAug 28, 12:00 PM1$175.69
ORD-7332Lucas MartinAug 3, 3:00 PM2$168.34
ORD-7327Kiran RaoAug 8, 10:00 AM3$160.99
ORD-7322Sofia RossiAug 13, 1:00 PM4$153.64
ORD-7317Omar HaddadAug 18, 4:00 PM5$146.29
ORD-7312Mei LinAug 23, 11:00 AM6$138.94
ORD-7336Mei LinAug 17, 11:00 AM3$110.22
ORD-7331Daniel OkaforAug 22, 2:00 PM4$102.87
ORD-7326Asha MenonAug 27, 9:00 AM5$95.52
ORD-7321Nadia KarimAug 2, 12:00 PM6$88.17
ORD-7316Lucas MartinAug 7, 3:00 PM7$80.82
ORD-7311Kiran RaoAug 12, 10:00 AM8$73.47
ORD-7335Kiran RaoAug 6, 10:00 AM5$44.75
ORD-7330Sofia RossiAug 11, 1:00 PM6$37.40
ORD-7325Omar HaddadAug 16, 4:00 PM7$30.05
ORD-7320Mei LinAug 21, 11:00 AM8$22.70
ORD-7315Daniel OkaforAug 26, 2:00 PM9$15.35
ORD-7310Asha MenonAug 1, 9:00 AM1$8.00

Selection with a toolbar

Checkbox selection, search and a bulk action that reflects the selection.

ClaimMemberTypeStatusAmount
CLM-20480Asha MenonOutpatientIn review$25.00
CLM-20481Sofia RossiHospital stayNeeds info$60.71
CLM-20482Daniel OkaforPharmacyIn review$96.42
CLM-20483Kiran RaoVisionApproved$132.13
CLM-20484Mei LinDentalIn review$167.84
CLM-20485Lucas MartinOutpatientNeeds info$203.55
CLM-20486Omar HaddadHospital stayIn review$239.26
CLM-20487Asha MenonPharmacyApproved$274.97
CLM-20488Sofia RossiVisionIn review$310.68
CLM-20489Daniel OkaforDentalNeeds info$346.39
CLM-20490Kiran RaoOutpatientIn review$382.10
CLM-20491Mei LinHospital stayApproved$417.81
CLM-20492Lucas MartinPharmacyIn review$453.52
CLM-20493Omar HaddadVisionNeeds info$489.23
CLM-20494Asha MenonDentalIn review$524.94
CLM-20495Sofia RossiOutpatientApproved$560.65
CLM-20496Daniel OkaforHospital stayIn review$596.36
CLM-20497Kiran RaoPharmacyNeeds info$632.07
CLM-20498Mei LinVisionIn review$667.78
CLM-20499Lucas MartinDentalApproved$703.49
CLM-20500Omar HaddadOutpatientIn review$739.20
CLM-20501Asha MenonHospital stayNeeds info$774.91
CLM-20502Sofia RossiPharmacyIn review$810.62
CLM-20503Daniel OkaforVisionApproved$846.33

Empty

An EmptyState in place of rows.

InvoiceIssuedAmount

No invoices yet

Invoices appear here after your first billing cycle closes.

Loading

Skeleton rows while data loads; the grid is aria-busy.

ReferenceRecipientDateStatusAmount
Loading
Loading
Loading
Loading
Loading

Accessibility

KeysAction
TabMoves 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 DownMoves to the first / last cell in the row, or by a page of rows.
EnterorSpace on a sortable headerSorts by that column; press again to reverse.
SpaceToggles selection of the focused row (selectionMode single or multiple).
EnterPerforms onRowAction on the focused row.
Ctrl/⌘ASelects 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-labelRequired
string

Names the table. Or pass aria-labelledby pointing at a visible heading.

columnsRequired
DataTableColumn<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.

rowsRequired
readonly T[]

The rows to show, already sorted and paged.

getRowIdRequired
(row: T) => string

A stable, unique id per row. Used for selection keys and onRowAction.

selectionMode
  • nonedefault
  • single
  • multiple

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) => void

Called with 'all' or a Set of row ids.

disabledKeys
Iterable<Key>

Row ids that can't be selected or actioned.

sortDescriptor
DataTableSortDescriptor

{ column, direction } of the current sort (controlled). Sort rows yourself — see useSortedRows.

onSortChange
(descriptor: DataTableSortDescriptor) => void

Called when a sortable header is pressed.

onRowAction
(id: string) => void

Called when a row is activated (click or Enter), e.g. to open its detail page.

isLoading
boolean

Shows skeleton rows and sets aria-busy on the grid.

Default false

loadingRowCount
number

Skeleton rows while loading; match your page size to avoid a jump.

Default 5

loadingLabel
string

Visually hidden row-header text of each skeleton row, so rows are never announced with empty headers.

Default 'Loading'

emptyState
ReactNode

Shown when rows is empty. Pass <EmptyState size="sm" …/> for a richer message.

Default 'No results.'

density
  • comfortable
  • compact

Overrides the theme density for this table (row 48/36px).

stickyHeader
boolean

Keeps the header visible while the body scrolls inside maxBlockSize.

Default true

maxBlockSize
number | string

Maximum height of the table's own scroll container.

className
string

Class 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

children
ReactNode

Search, filters and actions. First child sits at the start, last at the end; wraps when narrow.

DataTablePagination

pageRequired
number

Current page, 1-based.

pageSizeRequired
number

Rows per page.

totalCountRequired
number

Total rows across all pages.

onPageChange
(page: number) => void

Called with the new page.

formatSummary
(range: { start: number; end: number; total: number }) => ReactNode

Builds the range text (a polite live region).

Default "Showing 1–10 of 48"

landmark
boolean

Render 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 false

label
string

Accessible 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