02UI

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.

Structure and navigationBuild difficulty: Hard<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

Anatomy of a table: the caption above it, a column header, the sort control inside that header, one row, one cell, and a right-aligned numeric column.
  1. 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.
  2. 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.
  3. 3Sort control. A real button inside the header, with aria-sort on the <th> set to ascending, descending or none.
  4. 4Row. One record. Where a row has a name of its own, that cell is a <th scope="row"> rather than a <td>.
  5. 5Cell. One value. Format every cell in a column the same way, or the column stops being scannable whatever the alignment does.
  6. 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.

Recent invoices
InvoiceStatus
INV-2046Ostend Ferry2,410.00Paid
INV-2043Northbank Studio1,240.00Paid
INV-2044Halden and Co320.00Overdue
INV-2045Meridian Labs89.50Draft

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

Variants

InvoiceClientAmount
INV-2043Northbank Studio1,240.00
INV-2044Halden and Co320.00
INV-2045Meridian Labs89.50
InvoiceClientStatusAmount
INV-2043Northbank StudioPaid1,240.00
INV-2044Halden and CoOverdue320.00
INV-2045Meridian LabsDraft89.50
INV-2046Ostend FerryPaid2,410.00
InvoiceClientAmount
INV-2043Northbank Studio1,240.00
INV-2044Halden and Co320.00
INV-2045Meridian Labs89.50
InvoiceClientAmount
INV-2043Northbank Studio1,240.00
INV-2044Halden and Co320.00
INV-2045Meridian Labs89.50
INV-2046Ostend Ferry2,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

Default
ClientAmount
Halden320.00
Ostend2,410.00
Hover
ClientAmount
Halden320.00
Ostend2,410.00
Selected
ClientAmount
Halden320.00
Ostend2,410.00
Sorted
ClientAmount
Ostend2,410.00
Halden320.00
Loading
ClientAmount
Empty
ClientAmount
No invoices match these filters
StateWhat changesWatch out for
DefaultPlain rowCell padding should be the same on every row, including the last
HoverRow background shiftsOnly worth painting when the row is clickable or has row actions
SelectedStronger background, checkbox tickedMust be distinguishable from hover and from a zebra stripe
SortedArrow in the header, aria-sort setOnly one column sorted at a time unless you show the sort order explicitly
LoadingSkeleton rows at the current row heightKeep the header visible so the layout doesn't jump when data lands
EmptyA message in place of the rowsSay 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

KeyWhat happens
TabMoves 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 SpaceActivates the focused control, so sorts by that column or ticks that row
Arrow keysMove 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 containerReaches 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, descending or none on 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

Do
ClientAmount
Northbank Studio1,240.00
Halden and Co320.00
Meridian Labs89.50
Ostend Ferry2,410.00

Right-aligned, tabular figures, two decimal places on every row. The column can be ranked without any of it being read.

Don't
ClientAmount
Northbank Studio1240
Halden and Co320.00
Meridian Labs89.5
Ostend Ferry2410

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 &amp; 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