Table
Sorting and selection are controlled. The table draws the state and reports the intent; it holds nothing and sorts nothing.
Try it
| Status | Last seen | Actions | |||
|---|---|---|---|---|---|
Aoife Byrne Aoife Byrne aoife.byrne@example.com | lapsed | 1 | 4 months ago | EditDelete | |
Gary Martin Gary Martin gary.martin@example.com | pending | 3 | today | EditDelete | |
Lisa Roberts Lisa Roberts lisa.roberts@example.com | active | 14 | 2 days ago | EditDelete | |
Sanjay Choudhary Sanjay Choudhary sanjay.c@example.com | active | 27 | last week | EditDelete |
At 800 everything fits. At 560 the actions have gathered into “⋯” and Last seen has left; at 400 Visits has too, unless it is the column sorted by; at 320 the client and the actions stay.
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 actions are the things done to one row: two at most as buttons, the rest in a dropdown menu, so a list of records is not a list of buttons.
Columns that give way
A table measures its own width, not the screen’s, so a side panel narrows it the way a phone does. A column with a width keeps it and leaves whole. One without shares what the fixed columns leave, in proportion to its minWidth — 96 unless told, 160 for the primary — and shrinks to it. Past that it leaves, the lowest priority first; left out, the last column in the source leaves first. It comes back only when it fits whole.
The primary column, the selection and the actions never leave, and the column the reader sorted by is raised to stay. Before any column goes, the row’s actions gather into one “⋯”: the first two with an icon are buttons while there is room, and an action without an icon is always in the menu. Nothing that leaves is moved elsewhere: it is gone until there is room, so the columns nobody compares on belong on the row’s own page.
Text wraps and the row grows; truncate keeps a column to one line with an ellipsis, for a dense column of names. A header does not wrap: give a long one a larger minWidth. The rules are written into the page with the table, so they hold before any script runs. rowActions hands the menu functions, so the table that uses it is rendered by a client component.
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.
| Client | Visits | Last seen |
|---|---|---|
| Aoife Byrne | 1 | 4 months ago |
| Gary Martin | 3 | today |
| Lisa Roberts | 14 | 2 days ago |
| Sanjay Choudhary | 27 | last week |
Dense
The screen the system was made for, composed: a search, the Filters read as words, the table with its header pinned inside a bounded region, a bar that floats over the selection, and the Pagination as the footer. Seventy-two rows; pick a few, scroll the page, change the page size.
| Status | Location | ||||
|---|---|---|---|---|---|
Aoife Byrne Aoife Byrne aoife.byrne@example.com | Active | practitioner | Lisbon | 3 | |
Aoife Costa Aoife Costa aoife.costa@example.com | Invite pending | practitioner | Lisbon | 87 | |
Aoife Ferreira Aoife Ferreira aoife.ferreira@example.com | Active | practitioner | Lisbon | 75 | |
Aoife Ramos Aoife Ramos aoife.ramos@example.com | Active | practitioner | Lisbon | 39 | |
Beatriz Choudhary Beatriz Choudhary beatriz.choudhary@example.com | Invite pending | practitioner | Coimbra | 32 | |
Beatriz Lopes Beatriz Lopes beatriz.lopes@example.com | Active | practitioner | Coimbra | 68 | |
Beatriz Roberts Beatriz Roberts beatriz.roberts@example.com | Active | practitioner | Coimbra | 20 | |
Beatriz Sousa Beatriz Sousa beatriz.sousa@example.com | Active | practitioner | Coimbra | 104 | |
Carla Choudhary Carla Choudhary carla.choudhary@example.com | Invite pending | practitioner | Porto | 22 | |
Carla Lopes Carla Lopes carla.lopes@example.com | Active | practitioner | Porto | 58 |
stickyHeader pins the header while the region scrolls, and the region scrolls vertically only with maxHeight: sticky is held by the nearest scrolling ancestor, and the frame already scrolls sideways, so a header cannot stick to the page while the table keeps its own horizontal scroll. The line under the pinned header is a shadow, since a collapsed border scrolls away under a sticky cell in Chromium.
With bulkActions and a selection, a bar floats at the foot of the window and follows the page, never leaving the table: the count, what the caller puts there, and “Clear selection”, which the bar owns because the table owns the selection. It is drawn as an accent bar; here it is the Popover’s shell, because what a caller puts in it — icon buttons, a Switch — reads the theme’s text tokens, which on accent are illegible, and the theme has no tokens yet for controls on an inverse surface. Decided 2026-09-22, recorded as waiting on those tokens. The drawn stripe at the start of a selected row is kept.
The bar keeps to one row at every width. Give bulkActions a menu’s actions and they are drawn as a row’s are: those with an icon as buttons, named by a tooltip, the rest in “⋯”, and all of them gathered into one “⋯” when the bar has no room for the buttons. The count and Clear always show; below 25rem of table Clear is drawn as a ✕ and keeps its name. The row’s actions gather by the table’s width, since everything in a row is counted; the bar holds words whose width nothing counts, so its actions gather by the width of their own slot, the one part of the bar that gives way. Nodes of your own cannot gather: they scroll sideways inside their slot, with a thin scrollbar, rather than wrap. An action with checked is the drawn Switch, “Show only selected”, while the bar has room for it, measured in the page since its words have a width only the page knows; when it has not, it is tucked into “⋯” as a checkbox row, the buttons staying, and the label stays the same either way.
footer is a slot under the frame: the Pagination with total and pageSize says “1–10 of 72” and lets the size be picked, as drawn.
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. Current: the row being looked at, drawn with a ring, told by aria-current; a row can be current and selected.
| Client | Visits | Last seen |
|---|---|---|
| No clients yet. A client appears here after their first appointment. | ||
| Client | Visits | Last 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.
| Client | Visits | Last seen |
|---|---|---|
| Aoife Byrne | 1 | 4 months ago |
| Gary Martin | 3 | today |
| Lisa Roberts | 14 | 2 days ago |
| Sanjay Choudhary | 27 | last 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 when even the columns that never leave do not fit. A column that leaves is display: none, header and cells, so a screen reader is told the columns a sighted reader sees, and nothing is announced: a width changing is not an event in the content. An inline action is an icon button named by its tooltip; the “⋯” is named per row by rowActionsLabel.
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
| Prop | Type | Default |
|---|---|---|
caption | string | required |
captionVisible | boolean | false |
columns | Column<Row>[] | required |
rows | Row[] | required |
getRowId | (row: Row) => string | required |
density | 'comfortable' | 'compact' | follows data-density |
sort | Sort | null | — |
onSortChange | (next: Sort | null) => void | — |
selected | Readonly | — |
onSelectionChange | (next: Set<string>) => void | — |
selectionLabel | (row: Row) => string | Select row {n} |
rowAction | (row: Row) => React | — |
rowActions | (row: Row) => Dropdown | — |
rowActionsInline | number | 2 |
rowActionsLabel | (row: Row) => string | 'More actions' |
empty | React | 'No rows' |
loading | boolean | false |
stickyHeader | boolean | false |
maxHeight | number | string | — |
bulkActions | Dropdown | — |
bulkActionsInline | number | 5 |
bulkActionsLabel | string | 'More actions' |
bulkLabel | (count: number) => string | {n} selected |
clearSelectionLabel | string | 'Clear selection' |
footer | React | — |
currentId | string | null | — |
onCurrentChange | (id: string) => void | — |
| Prop | Type | Default |
|---|---|---|
key | string | required |
header | React | required |
cell | (row: Row) => React | required |
align | 'start' | 'center' | 'end' | 'start' |
sortable | boolean | false |
width | number (px) — fixed, leaves whole | flexible |
minWidth | number (px), padding included | 96; 160 primary |
priority | number, 1 the most important | source order |
truncate | boolean | false |
primary | boolean — never leaves | false |
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.