Tables & Data
Table Accessibility & Semantic Tables
Create accessible, screen-reader-friendly tables. Master ARIA attributes, semantic structure, and techniques for inclusive data presentation.
Why Table Accessibility Matters
Screen reader users navigate tables differently. Proper semantic structure and ARIA attributes help them understand table relationships and data context.
15%
of population has some form of disability
2.2B
people worldwide with vision impairment
Legal
requirement in many countries (ADA, WCAG)
Accessible Table Essentials
1. Always Use <caption>
HTML
<table>
<caption>Monthly Sales Report - Q4 2024</caption>
<thead>
<tr>
<th>Month</th>
<th>Sales</th>
<th>Growth</th>
</tr>
</thead>
<tbody>
<tr>
<td>October</td>
<td>$45,000</td>
<td>+12%</td>
</tr>
</tbody>
</table>
<!-- Screen reader announces: "Table: Monthly Sales Report - Q4 2024" -->2. Use <th> for Headers
⌠Bad: Using <td> for headers
HTML
<tr>
<td><strong>Name</strong></td>
<td><strong>Age</strong></td>
</tr>✅ Good: Using <th>
HTML
<tr>
<th>Name</th>
<th>Age</th>
</tr>3. Add scope Attribute
HTML
<table>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Price</th>
<th scope="col">Stock</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Laptop</th>
<td>$999</td>
<td>15</td>
</tr>
<tr>
<th scope="row">Mouse</th>
<td>$25</td>
<td>150</td>
</tr>
</tbody>
</table>
<!-- Screen reader announces: -->
<!-- "Laptop, Price: $999" -->
<!-- "Laptop, Stock: 15" -->Complex Table Accessibility
Using id and headers
For complex tables with multiple header levels:
HTML
<table>
<caption>Sales by Region and Quarter</caption>
<thead>
<tr>
<th id="region">Region</th>
<th id="q1" colspan="2">Q1</th>
<th id="q2" colspan="2">Q2</th>
</tr>
<tr>
<th id="blank"></th>
<th id="q1-units" headers="q1">Units</th>
<th id="q1-rev" headers="q1">Revenue</th>
<th id="q2-units" headers="q2">Units</th>
<th id="q2-rev" headers="q2">Revenue</th>
</tr>
</thead>
<tbody>
<tr>
<th id="north" headers="region">North</th>
<td headers="north q1 q1-units">150</td>
<td headers="north q1 q1-rev">$150k</td>
<td headers="north q2 q2-units">180</td>
<td headers="north q2 q2-rev">$180k</td>
</tr>
<tr>
<th id="south" headers="region">South</th>
<td headers="south q1 q1-units">120</td>
<td headers="south q1 q1-rev">$120k</td>
<td headers="south q2 q2-units">140</td>
<td headers="south q2 q2-rev">$140k</td>
</tr>
</tbody>
</table>
<!-- Screen reader announces: -->
<!-- "North, Q1 Units: 150" -->
<!-- "North, Q1 Revenue: $150k" -->ARIA Attributes for Tables
role="table"
When using div-based tables (not recommended, but sometimes necessary):
HTML
<div role="table" aria-label="Employee Directory">
<div role="rowgroup">
<div role="row">
<div role="columnheader">Name</div>
<div role="columnheader">Position</div>
</div>
</div>
<div role="rowgroup">
<div role="row">
<div role="cell">John Doe</div>
<div role="cell">Developer</div>
</div>
</div>
</div>
<!-- ARIA roles: -->
<!-- table, rowgroup, row, columnheader, rowheader, cell, gridcell -->âš ï¸ Important: Always prefer native HTML table elements. Only use ARIA roles when absolutely necessary (e.g., complex interactive grids).
aria-label and aria-describedby
HTML
<table aria-label="2024 Sales Performance">
<caption id="sales-caption">
Sales performance across all regions for 2024
</caption>
<!-- Table content -->
</table>
<!-- With description -->
<p id="table-desc">
This table shows year-over-year comparison.
Use arrow keys to navigate.
</p>
<table aria-describedby="table-desc">
<!-- Table content -->
</table>Sortable & Interactive Tables
Accessible Sort Buttons
HTML
<table>
<thead>
<tr>
<th>
<button aria-label="Sort by name"
aria-sort="none"
onclick="sort(this, 0)">
Name
<span aria-hidden="true">↕</span>
</button>
</th>
<th>
<button aria-label="Sort by age"
aria-sort="none"
onclick="sort(this, 1)">
Age
<span aria-hidden="true">↕</span>
</button>
</th>
</tr>
</thead>
<tbody>
<!-- Table rows -->
</tbody>
</table>
<script>
function sort(button, columnIndex) {
// Sort logic...
// Update ARIA
const allButtons = document.querySelectorAll('thead button');
allButtons.forEach(btn => btn.setAttribute('aria-sort', 'none'));
const sortDir = button.getAttribute('aria-sort') === 'ascending'
? 'descending'
: 'ascending';
button.setAttribute('aria-sort', sortDir);
// Update visual indicator
button.querySelector('span').textContent =
sortDir === 'ascending' ? '↑' : '↓';
}
</script>
<!-- aria-sort values: ascending, descending, none, other -->Visual Styling for Accessibility
Color Contrast
CSS
<style>
/* ✅ Good: WCAG AA compliant (4.5:1 contrast ratio) */
table {
color: #000;
background: #fff;
}
thead {
background: #2c3e50; /* Dark blue */
color: #ffffff; /* White text */
}
/* ⌠Bad: Low contrast */
table {
color: #999; /* Too light */
background: #fff;
}
</style>
<!-- Test contrast at: -->
<!-- - WebAIM Contrast Checker -->
<!-- - Chrome DevTools Lighthouse -->Focus Indicators
CSS
<style>
/* Visible focus for keyboard navigation */
button:focus,
a:focus {
outline: 3px solid #4CAF50;
outline-offset: 2px;
}
/* Interactive cells */
td:focus {
outline: 2px solid #2196F3;
background-color: #E3F2FD;
}
/* Don't remove focus outline! */
/* :focus { outline: none; } ⌠Never do this! */
</style>Zebra Striping (With Purpose)
CSS
<style>
/* Helps users track rows across wide tables */
tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
tbody tr:nth-child(odd) {
background-color: #ffffff;
}
/* Hover state */
tbody tr:hover {
background-color: #e9ecef;
cursor: pointer;
}
/* Selected row */
tbody tr.selected {
background-color: #cce5ff;
border-left: 4px solid #007bff;
}
</style>Responsive & Accessible
Screen Reader Only Text
HTML
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
</style>
<table>
<caption>
Product Inventory
<span class="sr-only">
This table shows current inventory levels.
Products low in stock are highlighted.
</span>
</caption>
<!-- Table content -->
</table>Mobile-Friendly & Accessible
HTML
<style>
@media screen and (max-width: 600px) {
/* Card layout on mobile */
thead {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
tbody tr {
display: block;
margin-bottom: 20px;
border: 2px solid #ddd;
}
tbody td {
display: block;
text-align: right;
padding: 10px;
border: none;
}
tbody td::before {
content: attr(data-label);
float: left;
font-weight: bold;
}
/* Maintain focus indicators */
tbody tr:focus,
tbody td:focus {
outline: 3px solid #4CAF50;
}
}
</style>
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
</tr>
</thead>
<tbody>
<tr tabindex="0">
<td data-label="Name">John Doe</td>
<td data-label="Email">john@example.com</td>
<td data-label="Phone">555-0100</td>
</tr>
</tbody>
</table>Testing for Accessibility
Manual Testing
- Keyboard Navigation: Tab through table, ensure all interactive elements are reachable
- Screen Reader: Test with NVDA (Windows), VoiceOver (Mac), or JAWS
- Zoom: Test at 200% zoom, ensure content remains readable
- High Contrast: Test in high contrast mode
Automated Testing Tools
- WAVE: Browser extension for accessibility testing
- axe DevTools: Chrome/Firefox extension
- Lighthouse: Built into Chrome DevTools
- Pa11y: Command-line tool
Screen Reader Keyboard Shortcuts
NVDA (Windows):
- T - Next table
- Ctrl + Alt + Arrow keys - Navigate cells
- Insert + F7 - List all tables
VoiceOver (Mac):
- VO + Cmd + T - Next table
- VO + Arrow keys - Navigate cells
- VO + U - Web rotor (list tables)
Complete Accessible Example
HTML
<table aria-label="Employee Performance Q4 2024">
<caption>
Employee Performance Review - Q4 2024
<span class="sr-only">
This table shows performance ratings and bonuses.
Employees are sorted by department.
</span>
</caption>
<colgroup>
<col style="width: 30%;">
<col style="width: 20%;">
<col style="width: 25%;">
<col style="width: 25%;">
</colgroup>
<thead>
<tr>
<th scope="col" id="name">Employee Name</th>
<th scope="col" id="dept">Department</th>
<th scope="col" id="rating">Performance Rating</th>
<th scope="col" id="bonus">Bonus</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" headers="name">Alice Johnson</th>
<td headers="dept">Engineering</td>
<td headers="rating">
<span aria-label="4 out of 5 stars">★★★★☆</span>
</td>
<td headers="bonus">$5,000</td>
</tr>
<tr>
<th scope="row" headers="name">Bob Smith</th>
<td headers="dept">Marketing</td>
<td headers="rating">
<span aria-label="5 out of 5 stars">★★★★★</span>
</td>
<td headers="bonus">$7,500</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="3">Total Bonuses</th>
<td>$12,500</td>
</tr>
</tfoot>
</table>
<style>
table {
border-collapse: collapse;
width: 100%;
margin: 20px 0;
}
caption {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 10px;
text-align: left;
}
th, td {
padding: 12px;
text-align: left;
border: 1px solid #ddd;
}
thead {
background-color: #2c3e50;
color: white;
}
tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
tbody tr:hover {
background-color: #e9ecef;
}
tfoot {
background-color: #ecf0f1;
font-weight: bold;
}
/* Accessibility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0,0,0,0);
}
/* Focus indicators */
th:focus, td:focus {
outline: 3px solid #4CAF50;
outline-offset: -3px;
}
</style>Accessibility Checklist
- ☑
<caption>element present - ☑
<th>used for all headers - ☑
scopeattribute on headers - ☑
<thead>,<tbody>,<tfoot>used - ☑ Complex tables use
idandheaders - ☑ Color contrast meets WCAG AA (4.5:1)
- ☑ Focus indicators visible
- ☑ Keyboard navigable
- ☑ Responsive design
- ☑ Tested with screen reader
- ☑ No tables for layout
- ☑ Clear data relationships