Design accessible data tables with proper header associations, sort controls, and pagination, plus accessible alternatives for complex charts and data visualizations.
Design accessible data tables with proper header associations, sort controls, and pagination, plus accessible alternatives for complex charts and data visualizations.
Screen readers announce table cells along with their associated header cells so users understand context. Without proper header associations, data cells are meaningless in isolation.
For simple tables (one row of headers, one column of headers), use scope attribute:
1<table>
2 <caption>Q1 2026 Sales by Region</caption>
3 <thead>
4 <tr>
5 <th scope="col">Region</th> <!-- Applies to entire column -->
6 <th scope="col">Sales</th>
7 <th scope="col">Target</th>
8 <th scope="col">Status</th>
9 </tr>
10 </thead>
11 <tbody>
12 <tr>
13 <th scope="row">North America</th> <!-- Applies to entire row -->
14 <td>$2.4M</td>
15 <td>$2.0M</td>
16 <td>
17 <!-- Status: color + icon + text (not color alone) -->
18 <span aria-label="Exceeded target">
19 <span aria-hidden="true">✓</span>
20 Exceeded
21 </span>
22 </td>
23 </tr>
24 </tbody>
25</table>For complex tables with merged cells, use headers + id:
1<table>
2 <tr>
3 <td></td>
4 <th id="q1" colspan="2">Q1</th>
5 <th id="q2" colspan="2">Q2</th>
6 </tr>
7 <tr>
8 <td></td>
9 <th id="sales">Sales</th>
10 <th id="cost">Cost</th>
11 <th id="sales2">Sales</th>
12 <th id="cost2">Cost</th>
13 </tr>
14 <tr>
15 <th id="north">North</th>
16 <!-- headers lists ALL header cells that apply to this data cell -->
17 <td headers="north q1 sales">$1.2M</td>
18 <td headers="north q1 cost">$0.8M</td>
19 <td headers="north q2 sales">$1.4M</td>
20 <td headers="north q2 cost">$0.9M</td>
21 </tr>
22</table>Sortable column headers and filter controls require ARIA to communicate state to screen readers:
1function SortableColumnHeader({
2 label,
3 field,
4 currentSort,
5 onSort,
6}: SortableHeaderProps) {
7 const isActive = currentSort.field === field;
8 const direction = isActive ? currentSort.direction : 'none';
9
10 return (
11 <th
12 scope="col"
13 aria-sort={direction} // 'ascending' | 'descending' | 'none'
14 >
15 <button
16 onClick={() => onSort(field)}
17 aria-label={`Sort by ${label}${
18 isActive
19 ? `, currently ${direction}`
20 : ''
21 }`}
22 >
23 {label}
24 {/* Visual sort indicator */}
25 <SortIcon direction={direction} aria-hidden="true" />
26 </button>
27 </th>
28 );
29}
30
31// Row selection with checkboxes
32function DataTable({ rows, onSelect }: DataTableProps) {
33 const [selected, setSelected] = useState<Set<string>>(new Set());
34
35 return (
36 <table aria-label="User accounts">
37 <thead>
38 <tr>
39 <th scope="col">
40 <input
41 type="checkbox"
42 aria-label="Select all rows"
43 checked={selected.size === rows.length}
44 onChange={toggleAll}
45 />
46 </th>
47 <th scope="col">Name</th>
48 <th scope="col">Email</th>
49 </tr>
50 </thead>
51 <tbody>
52 {rows.map(row => (
53 <tr key={row.id} aria-selected={selected.has(row.id)}>
54 <td>
55 <input
56 type="checkbox"
57 aria-label={`Select ${row.name}`}
58 checked={selected.has(row.id)}
59 onChange={() => toggleRow(row.id)}
60 />
61 </td>
62 <td>{row.name}</td>
63 <td>{row.email}</td>
64 </tr>
65 ))}
66 </tbody>
67 </table>
68 );
69}Tables need a caption that describes their purpose. The caption appears above the table and is announced by screen readers when the table is first encountered:
1<!-- caption: brief title for the table -->
2<table>
3 <caption>
4 Monthly active users by device type
5 <!-- For complex tables, add a more detailed description -->
6 <details>
7 <summary>Table description</summary>
8 This table shows monthly active users broken down by device
9 type (desktop, mobile, tablet) for each month of Q1 2026.
10 Data is rounded to the nearest thousand.
11 </details>
12 </caption>
13 <!-- ... table content ... -->
14</table>
15
16<!-- Alternative: aria-describedby for external description -->
17<p id="table-desc">
18 The following table compares subscription tiers across five dimensions.
19 Higher scores indicate better value.
20</p>
21<table aria-describedby="table-desc">
22 <!-- ... -->
23</table>For very large or complex tables, a paragraph before the table explaining what to look for ('The shaded rows indicate values above the target threshold') greatly helps cognitive accessibility, not just screen reader accessibility.
Charts and graphs convey patterns visually. Screen reader users and color-blind users need alternatives that convey the same information:
1function AccessibleBarChart({ data, title }: ChartProps) {
2 return (
3 <figure role="figure" aria-labelledby="chart-title">
4 <figcaption id="chart-title">{title}</figcaption>
5
6 {/* The visual chart (decorative for screen readers) */}
7 <div aria-hidden="true">
8 <BarChart data={data} />
9 </div>
10
11 {/* Text summary: what does this chart show? */}
12 <p>
13 Sales peaked in March at $2.4M, 20% above target.
14 January and February were below target by 5% and 8% respectively.
15 </p>
16
17 {/* Accessible data table: the same data in tabular form */}
18 <details>
19 <summary>View as data table</summary>
20 <table>
21 <caption>{title} — data table</caption>
22 <thead>
23 <tr>
24 <th scope="col">Month</th>
25 <th scope="col">Sales</th>
26 <th scope="col">Target</th>
27 </tr>
28 </thead>
29 <tbody>
30 {data.map(d => (
31 <tr key={d.month}>
32 <th scope="row">{d.month}</th>
33 <td>{d.sales}</td>
34 <td>{d.target}</td>
35 </tr>
36 ))}
37 </tbody>
38 </table>
39 </details>
40 </figure>
41 );
42}Responsive tables that reformat at mobile widths must maintain header-cell associations in the CSS-restructured layout:
1/* At mobile widths: stack cells vertically with data-label pseudo-labels */
2@media (max-width: 640px) {
3 table, thead, tbody, th, td, tr {
4 display: block;
5 }
6
7 thead tr {
8 /* Visually hide header row (still in DOM for screen readers) */
9 position: absolute;
10 top: -9999px;
11 left: -9999px;
12 }
13
14 td {
15 border: none;
16 position: relative;
17 padding-left: 45%;
18 }
19
20 /* CSS-generated labels from data attributes */
21 td::before {
22 content: attr(data-label);
23 position: absolute;
24 left: 6px;
25 width: 40%;
26 font-weight: bold;
27 }
28}1<!-- data-label attributes mirror the column headers -->
2<td data-label="Region:">North America</td>
3<td data-label="Sales:">$2.4M</td>The hidden thead is still readable by screen readers in browse mode. The data-label CSS pseudo-labels provide visible context for sighted users on small screens. For very complex tables, consider a 'Switch to card view' button that reformats the data as a list of cards, which is more accessible than a CSS-stacked table at mobile widths.
Find your application's most complex data table. Test it with a screen reader by navigating cell by cell — does every cell announcement include the relevant column and row header context? Add missing scope or headers attributes where needed.
Your progress is saved in this browser, on this device only.