Data Tables
Sortable, structured data with proper header associations and caption
Overview
Data tables are essential for presenting structured information like inventories, schedules, and reports. When built with proper <table> semantics, screen readers can navigate cell-by-cell and announce the relevant column and row headers for each cell. When tables are faked with <div> grids, all of that context is lost — users hear a flat stream of text with no way to understand which value belongs to which column.
WCAG Criteria:
- 1.3.1 Info and Relationships — table structure and header associations must be programmatically determinable
- 4.1.2 Name, Role, Value — interactive controls (like sort buttons) must expose their name, role, and current state
Key requirements:
- Never use ARIA
role="grid"just to make rows clickable — it changes the keyboard model entirely <caption>is like a heading for the table — it is announced when the user enters the tablescope="col"/scope="row"tell screen readers which header applies to which cells- Sort indicators should be programmatic (
aria-sort), not visual-only
Semantic Table
Semantic Table vs. Div Grid
View inaccessible code
<!-- Div grid — screen reader sees flat text, no table structure -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;">
<div>Product</div>
<div>Price</div>
<div>Stock</div>
<div>Widget A</div>
<div>$9.99</div>
<div>142</div>
<div>Widget B</div>
<div>$14.50</div>
<div>87</div>
<div>Widget C</div>
<div>$24.00</div>
<div>231</div>
</div>| Product | Price | Stock |
|---|---|---|
| Widget A | $9.99 | 142 |
| Widget B | $14.50 | 87 |
| Widget C | $24.00 | 231 |
View accessible code
<table>
<caption>Product Inventory</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Price</th>
<th scope="col">Stock</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Widget A</th>
<td>$9.99</td>
<td>142</td>
</tr>
<tr>
<th scope="row">Widget B</th>
<td>$14.50</td>
<td>87</td>
</tr>
<tr>
<th scope="row">Widget C</th>
<td>$24.00</td>
<td>231</td>
</tr>
</tbody>
</table>What’s wrong with the div grid?
- Screen readers cannot identify the structure as a table — there are no rows, columns, or headers
- Users cannot navigate cell-by-cell with table shortcuts (Ctrl+Alt+Arrow keys in most screen readers)
- When landing on “$9.99” in the div version, users have no idea which product or column it belongs to
- The visual grid layout is purely cosmetic — none of that structure reaches assistive technology
Predicted semantic summary (exact output varies):
| Version | Announcement when navigating to “$9.99” |
|---|---|
Inaccessible (<div> grid) | “$9.99” (no context — which product? which column?) |
Accessible (<table>) | “Price column, Widget A row, $9.99” |
Sortable Table
Sortable Table with aria-sort
| Product | Price ▲ | Stock |
|---|---|---|
| Widget A | $9.99 | 142 |
| Widget B | $14.50 | 87 |
| Widget C | $24.00 | 231 |
View inaccessible code
<!-- onclick on the th itself — no button, no aria-sort -->
<table>
<thead>
<tr>
<th>Product</th>
<th onclick="sortTable()" style="cursor:pointer;">
Price ▲
</th>
<th>Stock</th>
</tr>
</thead>
<tbody>
<tr><td>Widget A</td><td>$9.99</td><td>142</td></tr>
<tr><td>Widget B</td><td>$14.50</td><td>87</td></tr>
<tr><td>Widget C</td><td>$24.00</td><td>231</td></tr>
</tbody>
</table>| Product | Stock | |
|---|---|---|
| Widget A | $9.99 | 142 |
| Widget B | $14.50 | 87 |
| Widget C | $24.00 | 231 |
View accessible code
<table>
<caption>Product Inventory</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col" aria-sort="ascending">
<button onclick="toggleSort(this)">
Price <span aria-hidden="true">▲</span>
</button>
</th>
<th scope="col">Stock</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Widget A</th>
<td>$9.99</td><td>142</td>
</tr>
<tr>
<th scope="row">Widget B</th>
<td>$14.50</td><td>87</td>
</tr>
<tr>
<th scope="row">Widget C</th>
<td>$24.00</td><td>231</td>
</tr>
</tbody>
</table>What’s wrong with the inaccessible sort?
- The
<th>withonclickis not focusable by keyboard — users cannot Tab to it - Even if they could reach it,
<th>is not a button and cannot be activated with Enter or Space - The visual arrow (“Price ▲”) is the only sort indicator — screen readers do not convey it as a sort state
- Without
aria-sort, assistive technology has no way to tell the user which column is sorted or in which direction
Predicted semantic summary (exact output varies):
| Version | Announcement on the Price column header |
|---|---|
Inaccessible (onclick on <th>) | “Price ▲, column header” (arrow is just text, no sort state) |
Accessible (<button> + aria-sort) | “Price, button, column header, sorted ascending” |
| After clicking to toggle | ”Price, button, column header, sorted descending” |
Table design decisions
Give every data table a descriptive <caption>; use nearby headings for broader page structure. Simple tables can associate headers with scope. For irregular, multi-level headers, simplify the table where possible or use unique id values and headers references deliberately. Responsive presentation must preserve relationships: horizontal scrolling is often safer than converting cells into unrelated visual cards, while a card view needs equivalent labels for every value. Do not use table markup for layout.