Table

Sorting and selection are controlled. The table draws the state and reports the intent; it holds nothing and sorts nothing.

Try it

Clients
StatusLast seenActions
Aoife Byrne
Aoife Byrne
aoife.byrne@example.com
lapsed14 months ago
Gary Martin
Gary Martin
gary.martin@example.com
pending3today
Lisa Roberts
Lisa Roberts
lisa.roberts@example.com
active142 days ago
Sanjay Choudhary
Sanjay Choudhary
sanjay.c@example.com
active27last week

Sort by Client or Visits — three activations return to the natural order.

Choosing a table

A table is for records that share their fields: every row answers the same questions, so the eye can run down a column and compare. When each item is a paragraph, or the fields differ from item to item, it is a list. Sorting is the table’s reason to exist — a column the reader cannot sort is one they will scan — so put the columns in the order the reader asks the questions, the name first and the number they compare on beside it, and align numbers to the end in tabular figures so they line up.

Selection is for acting on several rows at once, and it costs a column; leave it off a table with nothing to do to a set. The trailing action is for the things done to one row, in a dropdown menu, so a list of records is not a list of buttons. A table that would need to scroll sideways on a laptop has too many columns: the ones nobody compares on belong in the row’s own page.

Anatomy and density

A header band on surface/base, rows on surface/raised, and hairlines between them. The drawn row is 72px, which is comfortable rather than dense; compact, at 48px, is an addition: a system sold on dense, data-heavy interfaces cannot have the table be the component that proves it least. A 48px row is for one line of content; an avatar beside two lines is what the 72px row exists for. Below 40rem of container width the table becomes a list — the container’s width, not the screen’s, so a table in a narrow panel collapses on a wide screen too.

Clients, compact
ClientVisitsLast seen
Aoife Byrne14 months ago
Gary Martin3today
Lisa Roberts142 days ago
Sanjay Choudhary27last week

Cell content is not the table's business

The Figma file draws twelve kinds of cell. Only some of them are structure — alignment, the selection column, the trailing action. The rest are things put inside a cell, so they are what cell returns: an Avatar beside two lines of text, a Badge, a number aligned to the end.

States

An empty table says what would be here and offers the way to fill it; a shrug — No rows — is the default only because the table cannot know. Loading keeps the table’s shape and reserves the rows’ place, so the page does not jump when they arrive.

Empty example
ClientVisitsLast seen
No clients yet. A client appears here after their first appointment.
Loading example
ClientVisitsLast seen
Loading rows

Accessibility

The caption is required, because a table with no accessible name is the most common table defect there is. It is hidden unless you ask for it, and captionVisible is what asks — shown here, aligned to the first column rather than centred the way a bare caption would be.

Clients
ClientVisitsLast seen
Aoife Byrne14 months ago
Gary Martin3today
Lisa Roberts142 days ago
Sanjay Choudhary27last week

aria-sort lands on the sorted column and no other, and the control is a button inside the header rather than a handler on it, so a keyboard can reach it.

The header checkbox derives its mixed state from the selection, and every row checkbox gets a name of its own — ten controls called “select row” are useless in a screen reader.

A selected row carries data-selected, not aria-selected: that attribute is only valid under role="grid", and on a plain table it is invalid ARIA that reads as correct.

The table sits in a focusable, labelled region, so a keyboard can scroll it sideways. Below 40rem of container width it collapses to a list: the header and the secondary columns go, and the primary cell, the selection checkbox and the row action stay. The drawing keeps only the first and the last of those — dropping the checkbox would remove selection on a phone rather than lay it out differently. The empty and loading cells are spared by the same rule, for the same reason: they are the only content those two states have.

The select-all checkbox in the header is disabled while loading is true, because it would otherwise write a selection derived from rows that are no longer on screen. The sort buttons deliberately stay live, because they only report intent to the caller.

The sortable header's hit target is the label rather than the whole cell — a deliberate choice — and the label raises its contrast on hover so a pointer user can see where the target is.

Props

Table props
PropTypeDefault
captionstringrequired
captionVisiblebooleanfalse
columnsColumn<Row>[]required
rowsRow[]required
getRowId(row: Row) => stringrequired
density'comfortable' | 'compact''comfortable'
sortSort | null
onSortChange(next: Sort | null) => void
selectedReadonlySet<string>
onSelectionChange(next: Set<string>) => void
selectionLabel(row: Row) => stringSelect row {n}
rowAction(row: Row) => ReactNode
emptyReactNode'No rows'
loadingbooleanfalse
Column props
PropTypeDefault
keystringrequired
headerReactNoderequired
cell(row: Row) => ReactNoderequired
align'start' | 'center' | 'end''start'
sortablebooleanfalse
widthstring (CSS)auto
primarybooleanfalse

Sort is { key: string; direction: 'asc' | 'desc' }; three activations of a sortable header go ascending, descending, and back to the natural order, which the table reports as null.