When to use
Use a real <table> whenever you have two or more columns of related data, where each cell makes sense in the context of its row and column. Pricing comparison tables, transaction lists, dashboards. Anywhere a sighted user reads “Issue 142, May 3rd, 8,214 reads” by scanning across.
Don’t use <table> for layout — that’s a 1990s anti-pattern, screen readers will announce table structure and the user has to navigate cell-by-cell. CSS Grid and Flexbox are the layout tools.
The pattern
| Picks | |||
|---|---|---|---|
| #142 | 5 | 8,214 | |
| #141 | 5 | 9,102 | |
| #140 | 5 | 7,588 | |
| #139 | 5 | 8,910 |
The native HTML elements give you almost everything. The four important pieces:
<table>
<caption>Recent Kindling issues — by issue number, date, and reader count</caption>
<thead>
<tr>
<th scope="col">Issue</th>
<th scope="col">Date</th>
<th scope="col">Picks</th>
<th scope="col">Reads</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">#142</th>
<td><time datetime="2026-05-03">3 May 2026</time></td>
<td>5</td>
<td>8,214</td>
</tr>
</tbody>
</table> What each piece does:
<caption>is the table’s accessible name. Screen readers announce it as the user enters the table. Always include one — it’s the first thing the user hears.scope="col"on column headers andscope="row"on row headers lets screen readers announce the relevant headers when reading a cell. (“Issue 142, Reads, 8,214.”)<thead>/<tbody>group rows semantically. Some screen readers announce the structural change.<time datetime="…">wraps human-formatted dates with a machine-readable form.
Sortable columns
Sorting goes inside the <th> and uses two attributes — aria-sort on the cell, and a <button> with the click handler:
<th scope="col" aria-sort="descending">
<button type="button" data-sort="issue">
Issue
<span aria-hidden="true" class="sort-icon">▾</span>
</button>
</th> aria-sort accepts "ascending", "descending", "none", or "other". Only one column at a time should have a sort value other than none — even if your sort logic supports multiple columns, the ARIA attribute represents the current primary sort.
The <button> makes the header keyboard-activatable. Don’t put a click handler on the <th> itself — it’s not focusable and screen readers announce it as plain text.
btn.addEventListener('click', () => {
const th = btn.closest('th');
const current = th.getAttribute('aria-sort');
const dir = current === 'ascending' ? 'descending' : 'ascending';
// Clear other columns; set this one
table.querySelectorAll('th[aria-sort]').forEach((o) => {
if (o !== th) o.removeAttribute('aria-sort');
});
th.setAttribute('aria-sort', dir);
// …re-sort and re-render rows
}); Numeric alignment
Numbers should be right-aligned for scanning (so “8,214” sits above “10,592” with their digits lined up). Add font-variant-numeric: tabular-nums to make the digits monospace within the table — keeps columns of numbers aligned even when the font isn’t fixed-width.
Empty cells
Leave them empty if there’s genuinely no data: <td></td>. Don’t put — or n/a unless that distinction matters — screen readers announce an empty cell as “blank”, which conveys “no value”.
Anti-pattern: the div-grid
Most “modern” component libraries reach for divs because they want unrestricted CSS layout:
<div class="table">
<div class="row header">
<div>Issue</div><div>Date</div><div>Reads</div>
</div>
<div class="row">
<div>#142</div><div>3 May 2026</div><div>8,214</div>
</div>
</div> The complete failure list:
- No table semantics — screen readers announce “142, May 3, 8214” with no idea these are related.
- No header relationships — the user can’t ask “what column is this in?” because there are no columns.
- No
<caption>— no accessible name for the data set. - No row/column count — SR users don’t know “row 4 of 17”.
If you absolutely need a div-grid for layout reasons (e.g. virtual scrolling), apply the table ARIA roles: role="table", role="row", role="columnheader", role="cell", role="rowgroup", plus aria-label for the caption and aria-rowcount/aria-colcount. It’s a lot of plumbing for the same end result.
Common variations
- Row-level actions — put a
<button>or<a>inside the last cell. Screen readers announce “row 4 of 17, … , Edit, button” — the cell context comes free. - Selectable rows — checkbox in the first cell, with a header “Select” in the matching
<th>. Add a “Select all” checkbox in<thead>if multi-select is supported. - Pagination / “load more” — outside the table. Announce result counts via a live region when they change (“17 of 200 issues shown”).
- Striping for scannability — purely visual; don’t rely on it for any meaning. Ensure adjacent stripes pass contrast against the foreground.
- Wide tables on small viewports — wrap in a scrollable container with
overflow-x: auto. Setmax-width: 100%on the<table>so it doesn’t break layout. Screen readers don’t care about visual scroll; the structure is intact.
Checklist
- Real
<table>(not divs) -
<caption>describing the data set - Column headers in
<thead>withscope="col" - Row headers (where appropriate) using
<th scope="row"> - Sortable columns:
<button>inside<th>,aria-sorton the<th> - Only one
aria-sortvalue other thannoneat a time - Visible focus on sort buttons
- Numbers right-aligned with
font-variant-numeric: tabular-nums - Wide tables wrap in
overflow-x: auto, not horizontally clipped - Empty cells left empty (don’t insert “n/a” reflexively)