Table
A table lays records out in rows and columns so values in the same column can be compared straight down the page. If nobody scans a column, the data doesn't need a table.
<table>Also called Data table, Data grid, Grid.
Almost everything a table does well comes from one property: values in a column line up, so the eye ranks them without reading them. Alignment, spacing and number formatting are where the work is. Sorting, selection and sticky headers are what get added afterwards, usually twice.
Anatomy of a table

- 1Caption. One line naming what the rows are, marked up as <caption>. It can be visually hidden, and it's what orients someone arriving by keyboard.
- 2Column header. A <th scope="col">. Every cell below it is read through it, which is why this markup matters more here than anywhere else.
- 3Sort control. A real button inside the header, with aria-sort on the <th> set to ascending, descending or none.
- 4Row. One record. Where a row has a name of its own, that cell is a <th scope="row"> rather than a <td>.
- 5Cell. One value. Format every cell in a column the same way, or the column stops being scannable whatever the alignment does.
- 6Numeric column. Right-aligned with tabular figures, so digits line up by place value and can be ranked without being read.
The header row is the part that carries the meaning. Every cell below it is understood through the header above it, which is why the markup connecting the two matters more here than in any other component on this site. Get <th> and <caption> right and a screen reader can read a cell as "Amount, 1,240.00". Get them wrong and it reads "1,240.00".
Here it is working. Sort by a column and watch the arrow and the row order move together.
| Invoice | Status | ||
|---|---|---|---|
| INV-2046 | Ostend Ferry | 2,410.00 | Paid |
| INV-2043 | Northbank Studio | 1,240.00 | Paid |
| INV-2044 | Halden and Co | 320.00 | Overdue |
| INV-2045 | Meridian Labs | 89.50 | Draft |
Click a column header to sort. The arrow and the header's aria-sort value move to that column together.
When to use a table
Use a table when
- People compare the same attribute across records, like amount, date or status
- Every record carries the same set of attributes
- Sorting or filtering by a column is part of the job
- Rows get acted on: selected, exported, edited in place
Reach for something else when
- There's one record on screen, so there's no column to scan down. Use a card instead.
- Each row is a choice someone makes one of, rather than a value they compare. Use a radio group instead.
- The rows are destinations and the columns carry nothing worth scanning. Use a list of links instead.
- Rows and columns are being used to position page content rather than to present data. Use a CSS grid layout instead.
Variants
| Invoice | Client | Amount |
|---|---|---|
| INV-2043 | Northbank Studio | 1,240.00 |
| INV-2044 | Halden and Co | 320.00 |
| INV-2045 | Meridian Labs | 89.50 |
| Invoice | Client | Status | Amount |
|---|---|---|---|
| INV-2043 | Northbank Studio | Paid | 1,240.00 |
| INV-2044 | Halden and Co | Overdue | 320.00 |
| INV-2045 | Meridian Labs | Draft | 89.50 |
| INV-2046 | Ostend Ferry | Paid | 2,410.00 |
| Invoice | Client | Amount | |
|---|---|---|---|
| INV-2043 | Northbank Studio | 1,240.00 | |
| INV-2044 | Halden and Co | 320.00 | |
| INV-2045 | Meridian Labs | 89.50 |
| Invoice | Client | Amount |
|---|---|---|
| INV-2043 | Northbank Studio | 1,240.00 |
| INV-2044 | Halden and Co | 320.00 |
| INV-2045 | Meridian Labs | 89.50 |
| INV-2046 | Ostend Ferry | 2,410.00 |
Default, zebra, selectable and compact. Zebra earns its place past about five columns, where the eye can slip a line between the first and the last.
Default. One hairline between rows, no fill. Works up to about four or five columns, where the eye can track a row without help.
Zebra. Alternating row backgrounds. Earns its place on wide tables, where a row is long enough that the eye can slip a line between the first and last column.
Selectable. A checkbox column at the start, plus a select-all in the header. The moment you add it, you owe people a count of what's selected and a way to clear it.
Compact. Shorter rows for people who live in the table all day and want more of it on screen. Most systems put a default row between 40 and 56px and a compact one between 32 and 36px, though nothing standardises those numbers.
States
| Client | Amount |
|---|---|
| Halden | 320.00 |
| Ostend | 2,410.00 |
| Client | Amount |
|---|---|
| Halden | 320.00 |
| Ostend | 2,410.00 |
| Client | Amount |
|---|---|
| Halden | 320.00 |
| Ostend | 2,410.00 |
| Client | Amount |
|---|---|
| Ostend | 2,410.00 |
| Halden | 320.00 |
| Client | Amount |
|---|---|
| Client | Amount |
|---|---|
| No invoices match these filters | |
| State | What changes | Watch out for |
|---|---|---|
| Default | Plain row | Cell padding should be the same on every row, including the last |
| Hover | Row background shifts | Only worth painting when the row is clickable or has row actions |
| Selected | Stronger background, checkbox ticked | Must be distinguishable from hover and from a zebra stripe |
| Sorted | Arrow in the header, aria-sort set | Only one column sorted at a time unless you show the sort order explicitly |
| Loading | Skeleton rows at the current row height | Keep the header visible so the layout doesn't jump when data lands |
| Empty | A message in place of the rows | Say whether it's empty because there's no data or because a filter matched nothing |
Selected and hover are the pair that goes wrong. If hover is a light fill and selection is a slightly darker fill of the same colour, a reader dragging the mouse across a table sees rows appear to select themselves. Give selection a different signal, like a left edge or a ticked checkbox, so it survives the mouse passing over it.
The two empty states are different problems and want different words. No data at all invites a first action ("Add your first invoice"). No matches for the current filter invites a way back ("No invoices match these filters. Clear filters"). Sending someone to "create your first invoice" when they have 400 of them behind a filter is a small insult. There's more on this on the empty state page.
How a table behaves
Sorting is per column and visible. One click sorts ascending, a second reverses it. The header shows which column is sorted and in which direction, because after any scroll the arrow is the only thing left saying so.
Sorting doesn't change what's in the table. Filtering does. Keeping the two separate in the interface saves people from wondering where a row went.
Column widths hold still between pages. If widths are computed from content, page 2 will re-lay out the whole table because its longest string differs. Set widths explicitly, or from the header, so paging feels like the same table.
A wide table scrolls inside its own container, not by pushing the page sideways. That container needs tabindex="0" and an accessible name, because a region that scrolls has to be reachable by keyboard (WCAG 2.1.1).
Row actions live in a consistent column. Last column, right-aligned, same position on every row. Actions that appear only on hover are invisible to touch and to keyboard, so keep them present and let hover raise their contrast.
Table accessibility
Keyboard
| Key | What happens |
|---|---|
Tab | Moves through the interactive things inside the table: sort buttons, checkboxes, row links, row action menus. A plain table has no focus stops of its own |
Enter or Space | Activates the focused control, so sorts by that column or ticks that row |
Arrow keys | Move between cells only in a full data grid (role="grid"), which you have to implement yourself. In a plain table the browser keeps arrows for scrolling |
Tab into the scroll container | Reaches a horizontally scrolling wrapper that has tabindex="0", so arrows can then scroll it |
The rest
- Use real table markup.
<table>,<thead>,<tbody>,<th>,<td>. A grid of divs looks identical and announces nothing, so a screen reader user loses the row and column position that makes the data readable at all. - Give the table a
<caption>. One line naming what the rows are. It can be visually hidden, and it's what tells someone landing on the table by keyboard what they've landed in. scope="col"on column headers,scope="row"where a row has one. Required once there are merged cells or more than one header row.- Sortable headers carry
aria-sort.ascending,descendingornoneon the<th>, with a real<button>inside it doing the sorting. The arrow glyph on its own says nothing to a screen reader. - Every row checkbox needs its own name. "Select row" repeated 40 times is no better than nothing. Name the record: "Select invoice INV-2043".
- Don't rely on colour for status. A green dot in a Status column needs its word next to it, whether that's a badge or plain text.
- Contrast. Sort arrows and status dots carry meaning, so they need 3:1 against their background (WCAG 1.4.11). Row separator lines are decorative and are exempt.
- Target size. Sort buttons, checkboxes and row action triggers all need 24 by 24 CSS pixels at Level AA (WCAG 2.5.8), which is where compact rows start to fight the spec.
What a table should say
Column headers are nouns, and short ones. "Amount", "Status", "Last active". A header that wraps to two lines sets the height of every header in the row.
Format every cell in a column the same way. One date format, one currency format, one decimal count. A column where 1,240 sits above 1240.00 can't be scanned, whatever the alignment says.
Empty cells need a mark. An en dash or "None" says the value is known to be absent. A blank cell reads as a bug, and a reader has no way to tell the two apart.
Truncate the least important column, and only that one. Middle-truncate identifiers where the ends are what distinguish them ("INV-2043…8871"), because cutting the tail off makes every row look the same.
Say what the numbers are in. "Amount (USD)" in the header beats a currency symbol repeated 40 times down the column.
Common table mistakes
| Client | Amount |
|---|---|
| Northbank Studio | 1,240.00 |
| Halden and Co | 320.00 |
| Meridian Labs | 89.50 |
| Ostend Ferry | 2,410.00 |
Right-aligned, tabular figures, two decimal places on every row. The column can be ranked without any of it being read.
| Client | Amount |
|---|---|
| Northbank Studio | 1240 |
| Halden and Co | 320.00 |
| Meridian Labs | 89.5 |
| Ostend Ferry | 2410 |
Left-aligned, proportional digits, a different decimal count per row. Four numbers that have to be read one at a time.
The one that costs the most is a table built from divs. It looks right in the design review, passes every visual check, and quietly removes the row and column relationship that made the data readable to anyone using a screen reader.
1. Divs instead of table markup. No row or column association, no caption, no header relationship. If it has rows and columns of data, it's a <table>.
2. Numbers left-aligned. The one change that costs nothing and improves a table most. Left-aligned currency turns a scannable column into 40 separate strings.
3. Proportional figures in a numeric column. Right alignment does nothing if the digits are different widths. font-variant-numeric: tabular-nums fixes it in one line.
4. Row actions that only appear on hover. Invisible on touch, unreachable by keyboard until focus happens to land there, and undiscoverable for everyone else.
5. A sort arrow with no aria-sort. The arrow is the only clue the column is sorted, and it's the one clue a screen reader can't see.
6. Selection that looks like hover. Two similar fills for two different states, so rows appear to select themselves as the mouse crosses them.
7. Twelve columns because the API returned twelve fields. Every column added makes every other column harder to scan. Start with what people compare, and put the rest behind a row expander or a column picker.
How to build a table
<table>
<caption>Recent invoices</caption>
<thead>
<tr>
<th scope="col">
<button type="button">Invoice</button>
</th>
<th scope="col">Client</th>
<th scope="col" aria-sort="descending">
<button type="button">Amount</button>
</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-2043</th>
<td>Northbank Studio</td>
<td>1,240.00</td>
<td>Paid</td>
</tr>
<tr>
<th scope="row">INV-2044</th>
<td>Halden & Co</td>
<td>320.00</td>
<td>Overdue</td>
</tr>
</tbody>
</table>
aria-sort sits on the <th> and moves to whichever column is sorted, with the other headers carrying no value at all. The invoice number is a <th scope="row"> because it names the row rather than being one of its values, which is what lets a screen reader read "INV-2044, Amount, 320.00" instead of reading a number with no subject.
Reach for role="grid" only if you're building spreadsheet-style arrow key navigation between cells. The role promises that behaviour, and adding it without the keyboard implementation makes the table harder to use than leaving it alone.
Edge cases
A very wide table. Scroll it inside a focusable, labelled container and pin the first column so rows stay identifiable. Pinning costs a position: sticky and a background colour on the pinned cells, or the scrolling content shows through.
Thousands of rows. Virtualise or paginate. A browser holding 5,000 rows in the DOM will drop frames on scroll, and every sort has to touch all of them.
Merged cells. colspan and rowspan break simple header inference, so add scope or headers explicitly. If a table needs both, it's often two tables.
Editable cells. The moment a cell holds an input, you owe people a save model, a validation state and a way to undo. That's closer to a form than a table, and it should follow the text field rules.
One row of results. Keep the table rather than switching layout. A table that turns into a card at a certain row count teaches people two layouts for one thing.
Tables in real products
Stripe's Dashboard puts amount, date and status in fixed positions, right-aligns amounts, and keeps a persistent filter bar above the table so the current view is always readable as a sentence.
Linear's issue list stays a table on the desktop and drops to a two-line list on narrow screens, keeping the identifier and title and losing the columns that were only there for comparison.
GitHub's file browser is a table doing the least a table can do: name, last commit message, time. Three columns, one of them a link, and no sorting, because nobody compares commit messages down a column.
Common questions
- Should numbers be right-aligned in a table?
- Right-align anything people compare by size: amounts, quantities, percentages. Digits then line up by place value, so 9.99 and 1,240.00 can be ranked without reading either of them. Pair it with tabular figures (font-variant-numeric: tabular-nums) so every digit takes the same width, otherwise proportional numerals break the alignment you just built. Identifiers like order numbers are read rather than compared, so they can stay left.
- What should a table do on a phone?
- Three options, in rough order of how often they're right. Scroll horizontally inside a labelled, keyboard-focusable container, which keeps the table a table. Drop columns by priority and put the rest behind a row expander. Or switch to a list of cards, one card per record, which reads well and quietly removes the ability to compare down a column. Pick the third only when comparing was never the point.
- Do I still need scope on table headers?
- For a simple table with one header row, browsers and screen readers infer the association, so scope="col" adds little. Add it anyway once the table has row headers, more than one header row, or any merged cells, because that's the point where inference stops being reliable. It costs one attribute and removes a whole class of bug.
- How many rows before I paginate?
- There's no standard number. What matters is the browser: past a few hundred rows in the DOM, scrolling and sorting start to stutter on mid-range phones. Most product tables land between 25 and 50 rows a page. Above that, either paginate or virtualise the list, and give people a rows-per-page control if they work in it daily.
- Zebra striping or row borders?
- Borders for narrow tables, striping for wide ones. Striping earns its place when a row is wide enough that the eye can slip a line between the first and last column, which is roughly 5 columns and up. On a 3-column table it adds visual noise for a problem nobody had. Whichever you pick, keep the contrast between the two row backgrounds subtle enough that stripes never read as selection.
Last reviewed 30 JUL 2026
