A table sets out a short list of records in rows and columns, so people can read them and compare the values down a column. It only shows the rows: for sorting, selecting or moving between rows with the keyboard, use the data table.
workload
status
cpu (cores)
restarts
api-7f9c
running
0.42
0
worker-2
running
1.18
2
queue-44ae
restarting
0.03
7
db-0
running
0.61
0
when to use
To show a short set of records that people only read, all with the same fields, so they can compare the values down a column: workloads, volumes, recent runs.
When every row fits on the screen, and nobody needs to sort the rows, select them or act on them.
As the body of a panel, in a card with no padding inside, so the table meets the card's frame.
when to use something else
For a list people work through. When people sort it, select rows, group them, load more or move between rows with the keyboard, use the data table. This table has none of that, and adding it on top is how support for the keyboard and for screen readers gets lost.
For a screen whose job is to find items and act on them. That is a worklist: a filter bar, a data table and a toolbar.
For layout. A table tells a screen reader that it holds rows and columns, which means nothing for a form. Lay a form out with a stack instead; forms and settings shows how.
For the details of one record. A single row with many columns is hard to read, and it scrolls sideways. List the labels and values in a card instead.
anatomy
Frame. A thin border around the whole table, as wide as its container. A table wider than its frame scrolls sideways inside it.
Header row. A short label for each column, in small monospace and lowercase, on a slightly darker background. A label never wraps onto a second line.
Body row. One record. Its cells lighten under the pointer. Rows aren't striped: every row has the same background, and a hairline under each one keeps them apart. The last row has no line of its own; the frame closes the table.
Cell. One value, in monospace, with room around it, centred from top to bottom.
Empty row. When there are no rows, one cell across the whole table says so, in small, dim, centred text.
variants and states
dense
A dense table has less space around each cell, so more rows fit on the screen: useful for long lists and side panels. More on density is in layout.
workload
status
cpu (cores)
restarts
api-7f9c
running
0.42
0
worker-2
running
1.18
2
queue-44ae
restarting
0.03
7
db-0
running
0.61
0
cells with more than text
A cell can hold more than text. Tags go in as badges. The state of the record goes in as a status, never as a coloured badge.
workload
labels
cpu (cores)
restarts
api-7f9c
http
0.42
0
worker-2
gpubatch
1.18
2 in 24h
queue-44ae
none
0.03
7 in 24h
db-0
stateful
0.61
0
empty
With no rows, a single row says so. It reads “no rows” until you give it the table's own words; see content, below.
workload
status
cpu (cores)
restarts
no workloads in this project
wider than its frame
A table wider than its container scrolls sideways inside its frame. The page itself doesn't scroll sideways.
workload
node
cpu (cores)
memory (mib)
restarts
age
image
api-7f9c
worker-1
0.42
312
0
4d
registry.example.com/api:2.4.1
worker-2
worker-2
1.18
2048
2
4d
registry.example.com/worker:2.4.1
db-0
worker-3
0.61
4096
0
19d
registry.example.com/db:16.2
built from its parts
Some tables need more than plain columns: a header over two columns, a row header, a cell that spans several rows or columns. Such a table is built from the table's parts, and it looks the same.
volume
space (gb)
used
free
storage-01
412
612
storage-02
96
928
storage-03
780
244
behaviour
The table itself takes no focus; what you put in its cells does. tab moves through the links and buttons in the cells, in reading order.
Rows lighten under the pointer, but a click on a row does nothing. There is no row selection, no row action and no sorting.
While a table is wider than its frame, the frame becomes a tab stop of its own: tab reaches it, and the left and right arrow keys scroll it sideways.
The full keyboard map is on the table's React page.
accessibility
Underneath, the table is a real table, so a screen reader's own table commands work: people can move by cell, by row and by column, and hear each cell with the label of its column.
Name every table, with the heading above it or with a label of its own.
Write the column labels as they should be read. The lowercase look is only a style; a screen reader reads the text as you wrote it.
Say a state in words, with a status. Colour in a cell is never announced.
A frame that scrolls gets the same name as its table, and shows the focus outline while it has focus. Where you can, keep a table narrow enough to fit, and put the columns that matter first.
A row header, a first cell that names its row, needs the table built from its parts.
Changes to the rows are not announced. When rows change because of something the person did, say what happened; message handling shows how.
Today: a row header takes the look of a column label, in small, muted monospace with the same spacing, and in the last row it keeps the line under it.
content
Column labels. A noun of one or two words, lowercase: “workload”, “size”, “last run”. Put the unit in the label, “cpu (cores)”, rather than in every cell.
Numbers. Align them to the right, and give them the same number of decimals down a column, so the digits line up.
Empty. Say what is missing, in the table's own words: “no workloads in this project”, not “no data”.
A failed load. It is not an empty table. Show the error in a banner above it. When an empty table needs an explanation or a next step, see empty states.
do and don't
do
workload
cpu (cores)
api-7f9c
0.42
worker-2
12.10
db-0
1.05
Numbers aligned to the right, with the same number of decimals and the unit in the column label: the digits line up.
don't
workload
cpu
api-7f9c
0.42 cores
worker-2
12.1 cores
db-0
1 core
Numbers aligned to the left, with different decimals and the unit in every cell: nothing lines up.
do
volume
size (gb)
no volumes in this project
The empty row says what is missing, in the table's own words.
don't
volume
size (gb)
error: could not load volumes
A failed load is not an empty list. Say the error in a banner above the table.
related
Data table: the table people work through, with sorting, selection, groups, more rows on demand and the keyboard.
Card: the frame a table usually sits in, with no padding inside.
Empty states: when one line in an empty row isn't enough.
specs
part
spec
Frame
A 1px --border on every side, full width, overflow-x: auto. While the table is wider than the frame, the frame is a focusable region (tabindex="0", role="region"), named by the table's aria-label or aria-labelledby (“table” without either), with a 2px --focus-ring outline 2px outside it on keyboard focus.
Header row
On --bg-2. Every label is a th with scope="col": JetBrains Mono 600, 11px, letter-spacing 0.04em, --fg-muted, lowercased with text-transform, never wrapping. Padding 8px 12px, a 1px --border rule below.
Body row
On hover, its cells turn --surface-2. No zebra stripes. The last row drops its bottom rule; the frame closes it.
Cell
JetBrains Mono 13px, --fg-2 on --surface, padding 12px, vertically centred, a 1px --border rule below.
Empty row
One cell across every column: mono 12px, --fg-dim, centred, padding 24px 12px.
Row header
Built from the parts: a Th with scope="row". Today it takes the header look (11px --fg-muted mono, padding 8px 12px) and keeps its bottom rule in the last row.
setting
in code
what it does
Dense
dense
Header and body cells padded 4px 8px.
Empty words
empty
The text of the empty row; no rows by default.
Alignment
align on a column: 'left' (the default), 'right' or 'center'
Aligns the label and the cells of that column. Numbers go 'right'.
Cells with more than text
render on a column
Draws the cell yourself: badges, a Status, a count in words.
Name
aria-label, or aria-labelledby with the id of the heading above
Names the table, and its frame while it scrolls.
Built from its parts
Thead, Tbody, Tr, Th and Td, in a native table with className="nc-table"
A grouped header (colSpan, rowSpan), a row header (Th scope="row"), a cell that spans.
In a card
flush on the Card
The card's body has no padding, so the table meets its frame.