Patterns Data table

Data table

Tabular data with column headers, row headers, a caption, and optional sorting. Use the native <table> element — div-grids never quite match it for AT.

Reviewed against the methodology checklist Updated

Try the keyboard

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

Sortable data table
Recent Kindling issues — by issue number, date, and reader count
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:

HTML
<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:

Sortable columns

Sorting goes inside the <th> and uses two attributes — aria-sort on the cell, and a <button> with the click handler:

HTML
<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.

JS
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:

HTML
<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:

  1. No table semantics — screen readers announce “142, May 3, 8214” with no idea these are related.
  2. No header relationships — the user can’t ask “what column is this in?” because there are no columns.
  3. No <caption> — no accessible name for the data set.
  4. 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

Checklist

Screen reader transcript

Voiced by George — reading what VoiceOver on macOS Safari announces. Other readers (NVDA, JAWS) phrase things differently; the meaning is what matters.

Screen reader announcements for each step
ListenYou doScreen reader says
Move screen-reader cursor onto a cellIssue, column, 4 of 4. Date, May 3 2026.
Tab onto a sortable headerIssue, sorted descending, button.
Press EnterSorted ascending. (table re-orders; SR reannounces)

Screen reader transcript — anti-pattern

Same interaction, but on the broken version above. Notice what the user is missing.

Screen reader announcements for each step
ListenYou doScreen reader says
Move SR cursor onto a "cell" in a div-grid142 (no row/column context — just a stream of values)

WCAG references