Table Accessibility Guide: Data Tables and WCAG Compliance
Table accessibility ensures that data presented in tabular format is understandable to screen reader users and other assistive technology users. Without proper markup, tables become incomprehensible—screen readers announce cells without context, and users can't understand relationships between data. WCAG requires that tables convey structure programmatically (1.3.1) so assistive technology can interpret and present them meaningfully.
This guide covers making data tables accessible: proper headers, captions, complex table patterns, and testing for WCAG compliance.
Q: What makes a data table WCAG compliant?
A: WCAG-compliant data tables have: proper <th> header cells with scope attributes, relationships between headers and data cells established programmatically, table captions or summaries describing the table purpose, and simple structure that avoids nested tables or complex spanning when possible.
Data Tables vs Layout Tables
Data Tables
Data tables present information in rows and columns where relationships between cells matter.
Examples:
- Product comparison tables
- Pricing tables
- Schedule/calendar data
- Statistical data
- Search results with multiple attributes
Requirements:
- Use proper table markup (
<table>,<th>,<td>) - Associate headers with data cells
- Provide caption describing table purpose
Layout Tables (Avoid)
Layout tables use table markup for visual positioning, not data relationships.
Problems:
- Screen readers announce "table" when there's no tabular data
- Confuses data table navigation commands
- Usually better achieved with CSS
If unavoidable:
<!-- Mark layout tables as presentational -->
<table role="presentation">
<!-- Layout content -->
</table>Better approach: Use CSS Grid or Flexbox for layout.
Basic Table Structure
Essential Elements
<table>
<caption>Quarterly Sales by Region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
<th scope="col">Q4</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>$45,000</td>
<td>$52,000</td>
<td>$48,000</td>
<td>$61,000</td>
</tr>
<tr>
<th scope="row">South</th>
<td>$38,000</td>
<td>$41,000</td>
<td>$39,000</td>
<td>$45,000</td>
</tr>
<tr>
<th scope="row">East</th>
<td>$62,000</td>
<td>$68,000</td>
<td>$71,000</td>
<td>$75,000</td>
</tr>
<tr>
<th scope="row">West</th>
<td>$51,000</td>
<td>$55,000</td>
<td>$49,000</td>
<td>$58,000</td>
</tr>
</tbody>
</table>Caption Element
The <caption> provides a title/description for the table:
<table>
<caption>Product comparison: Features by plan tier</caption>
<!-- Table content -->
</table>Caption best practices:
- Describe what the table shows
- Keep it concise but informative
- Position at start of table element
- Don't hide captions unless providing alternative
Header Cells (<th>)
Header cells identify column or row labels:
<!-- Column header -->
<th scope="col">Product Name</th>
<!-- Row header -->
<th scope="row">Premium Plan</th>The `scope` attribute:
scope="col": Header applies to column belowscope="row": Header applies to row to the rightscope="colgroup": Header applies to column groupscope="rowgroup": Header applies to row group
Screen Reader Table Navigation
How Screen Readers Present Tables
Screen readers provide special table navigation:
NVDA/JAWS:
- Ctrl+Alt+Arrow keys: Move between cells
- Announces header context for each cell
- Can list all tables on page
VoiceOver:
- VO+Arrow keys in table
- Announces column/row headers with each cell
What users hear:
Cell: "$45,000"
Without headers: "45 comma 000 dollars"
With headers: "Q1, North, 45,000 dollars"Why Headers Matter
Without proper headers:
"Table with 5 columns and 5 rows"
"Row 1, Column 1: Region"
"Row 1, Column 2: Q1"
...
"Row 2, Column 2: 45,000" <!-- No context! -->With proper headers:
"Table: Quarterly Sales by Region, 5 columns, 5 rows"
"Region, column header"
"Q1, column header"
...
"North row, Q1 column: $45,000" <!-- Context provided! -->Complex Tables
Multi-Level Headers
When tables have multiple levels of headers:
<table>
<caption>Sales Performance: 2024</caption>
<thead>
<tr>
<th rowspan="2" scope="col">Region</th>
<th colspan="2" scope="colgroup">Q1-Q2</th>
<th colspan="2" scope="colgroup">Q3-Q4</th>
</tr>
<tr>
<th scope="col">Revenue</th>
<th scope="col">Units</th>
<th scope="col">Revenue</th>
<th scope="col">Units</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>$97,000</td>
<td>1,240</td>
<td>$109,000</td>
<td>1,380</td>
</tr>
<!-- More rows -->
</tbody>
</table>Using headers Attribute
For complex tables where scope isn't sufficient, use explicit headers:
<table>
<caption>Office Schedule</caption>
<tr>
<td></td>
<th id="mon" scope="col">Monday</th>
<th id="tue" scope="col">Tuesday</th>
</tr>
<tr>
<th id="morning" scope="row">Morning</th>
<td headers="mon morning">Team Meeting</td>
<td headers="tue morning">Development</td>
</tr>
<tr>
<th id="afternoon" scope="row">Afternoon</th>
<td headers="mon afternoon">Client Calls</td>
<td headers="tue afternoon">Code Review</td>
</tr>
</table>Grouped Headers
<table>
<caption>Product Inventory</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">In Stock</th>
<th scope="col">On Order</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="rowgroup" colspan="4">Electronics</th>
</tr>
<tr>
<th scope="row">Laptops</th>
<td>45</td>
<td>20</td>
<td>65</td>
</tr>
<tr>
<th scope="row">Tablets</th>
<td>32</td>
<td>15</td>
<td>47</td>
</tr>
<tr>
<th scope="rowgroup" colspan="4">Accessories</th>
</tr>
<tr>
<th scope="row">Chargers</th>
<td>120</td>
<td>50</td>
<td>170</td>
</tr>
</tbody>
</table>E-Commerce Table Patterns
Product Comparison Table
<table class="comparison-table">
<caption>Compare Running Shoes</caption>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Nike Air Max</th>
<th scope="col">Adidas Ultraboost</th>
<th scope="col">New Balance 990</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Price</th>
<td>$150</td>
<td>$180</td>
<td>$185</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>10.5 oz</td>
<td>10.9 oz</td>
<td>12.6 oz</td>
</tr>
<tr>
<th scope="row">Cushioning</th>
<td>Air Max</td>
<td>Boost</td>
<td>ENCAP</td>
</tr>
<tr>
<th scope="row">Best For</th>
<td>Casual/lifestyle</td>
<td>Running</td>
<td>Walking/standing</td>
</tr>
</tbody>
</table>Pricing Table
<table class="pricing-table">
<caption>Subscription Plans</caption>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">
<span class="plan-name">Basic</span>
<span class="plan-price">$9/mo</span>
</th>
<th scope="col">
<span class="plan-name">Pro</span>
<span class="plan-price">$29/mo</span>
</th>
<th scope="col">
<span class="plan-name">Enterprise</span>
<span class="plan-price">Custom</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Users</th>
<td>1</td>
<td>5</td>
<td>Unlimited</td>
</tr>
<tr>
<th scope="row">Storage</th>
<td>10 GB</td>
<td>100 GB</td>
<td>Unlimited</td>
</tr>
<tr>
<th scope="row">Support</th>
<td>Email</td>
<td>Priority email</td>
<td>24/7 phone</td>
</tr>
<tr>
<th scope="row">API Access</th>
<td>
<span class="visually-hidden">Not included</span>
<span aria-hidden="true">—</span>
</td>
<td>
<span class="visually-hidden">Included</span>
<span aria-hidden="true">âś“</span>
</td>
<td>
<span class="visually-hidden">Included</span>
<span aria-hidden="true">âś“</span>
</td>
</tr>
</tbody>
</table>Order Summary Table
<table class="order-summary">
<caption>Order Summary</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Quantity</th>
<th scope="col">Price</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Blue Running Shoes (Size 10)</th>
<td>1</td>
<td>$89.99</td>
<td>$89.99</td>
</tr>
<tr>
<th scope="row">Athletic Socks (3-pack)</th>
<td>2</td>
<td>$12.99</td>
<td>$25.98</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="3">Subtotal</th>
<td>$115.97</td>
</tr>
<tr>
<th scope="row" colspan="3">Shipping</th>
<td>$5.99</td>
</tr>
<tr>
<th scope="row" colspan="3">Tax</th>
<td>$9.76</td>
</tr>
<tr>
<th scope="row" colspan="3"><strong>Total</strong></th>
<td><strong>$131.72</strong></td>
</tr>
</tfoot>
</table>Responsive Tables
Horizontal Scrolling
<div class="table-container" tabindex="0" role="region"
aria-label="Product comparison, scrollable">
<table>
<!-- Full table content -->
</table>
</div>.table-container {
overflow-x: auto;
max-width: 100%;
}
.table-container:focus {
outline: 3px solid #005fcc;
}Stacked Tables (Mobile)
<table class="responsive-table">
<caption>Product Details</caption>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Color</th>
<td data-label="Color">Blue</td>
</tr>
<tr>
<th scope="row">Size</th>
<td data-label="Size">Medium</td>
</tr>
</tbody>
</table>@media (max-width: 600px) {
.responsive-table thead {
display: none;
}
.responsive-table tr {
display: block;
margin-bottom: 1rem;
border: 1px solid #ccc;
}
.responsive-table td {
display: flex;
justify-content: space-between;
}
.responsive-table td::before {
content: attr(data-label);
font-weight: bold;
}
}Testing Table Accessibility
Automated Testing
TestParty identifies table accessibility issues:
- Missing header cells
- Missing scope attributes
- Empty table headers
- Tables without captions
- Layout tables with data table markup
Manual Testing
Screen reader testing:
- Navigate to table
- Use table navigation commands
- Verify headers announced with each cell
- Check if relationships make sense
Checklist:
- [ ] Table has caption or aria-label
- [ ] All headers use
<th>elements - [ ] Headers have appropriate scope
- [ ] Complex tables use headers attribute
- [ ] Data cells contain meaningful content
- [ ] Table structure is as simple as possible
- [ ] Responsive behavior preserves accessibility
Common Table Mistakes
Missing or Wrong Markup
<!-- Wrong: Using divs/spans instead of table elements -->
<div class="table">
<div class="row">
<span class="cell header">Name</span>
<span class="cell">John</span>
</div>
</div>
<!-- Wrong: Styling text to look like headers -->
<td><strong>Product Name</strong></td>
<!-- Right: Proper semantic markup -->
<th scope="col">Product Name</th>Missing Scope
<!-- Wrong: No scope -->
<th>Region</th>
<!-- Right: Scope specified -->
<th scope="col">Region</th>
<th scope="row">North</th>Icon-Only Content
<!-- Wrong: Screen readers can't interpret -->
<td>âś“</td>
<td>âś—</td>
<!-- Right: Text alternatives provided -->
<td>
<span class="visually-hidden">Included</span>
<span aria-hidden="true">âś“</span>
</td>
<td>
<span class="visually-hidden">Not included</span>
<span aria-hidden="true">âś—</span>
</td>FAQ Section
Q: Should every table have a caption?
A: Tables should have some form of accessible name—either <caption>, aria-label, or aria-labelledby. Captions are preferred as they're visible to all users, but alternatives work when visual captions aren't desired.
Q: When should I use the headers attribute?
A: Use headers when scope isn't sufficient—typically for complex tables with multi-level headers, irregular structures, or cells that span multiple rows/columns in ways that break simple scope relationships.
Q: Are sortable tables accessible?
A: They can be. Use aria-sort on sortable column headers, provide clear button controls for sorting, announce sort changes to screen readers, and ensure keyboard operation of sort controls.
Q: How do I handle empty cells?
A: Empty cells should convey meaning. Use "N/A", "None", or "—" with visually hidden text explaining what the empty value means. Truly empty cells should still have <td></td> markup.
Q: Can I use CSS Grid instead of tables for tabular data?
A: Use semantic <table> markup for actual data tables. CSS can style the table, but the underlying structure should be table elements. CSS Grid/Flexbox for layout is fine; for tabular data, use tables.
Key Takeaways
- Use semantic table markup (
<table>,<th>,<td>) for data tables, not divs styled to look like tables.
- Every header cell needs scope attribute indicating whether it applies to column or row.
- Provide table caption or aria-label so users understand what the table presents before navigating it.
- Complex tables need explicit associations using the headers attribute when scope alone isn't sufficient.
- Test with screen readers to verify header associations actually work and make sense.
- Keep tables as simple as possible. Simpler tables are more accessible and easier to maintain.
Conclusion
Table accessibility is essential for presenting data that all users can understand. Without proper headers and structure, screen reader users encounter meaningless strings of numbers and text with no context. With proper markup, they can navigate efficiently and understand data relationships.
TestParty identifies table accessibility issues—missing headers, incorrect scope, empty header cells—and provides specific fixes. For e-commerce sites with product comparisons, pricing tables, and order summaries, accessible tables ensure all customers can make informed decisions.
Ready to fix your table accessibility? Get a free accessibility scan to identify table issues across your site.
Related Articles:
- Screen Reader Testing Guide: NVDA, JAWS, and VoiceOver
- ARIA Best Practices: Accessible Component Patterns
- Form Accessibility Guide: Labels, Errors, and WCAG Compliance
About this article: TestParty's editorial team worked with AI research and writing tools to develop this content on digital accessibility. Our specialists in automated WCAG compliance reviewed the material for accuracy. However, accessibility requirements depend on your specific context, jurisdiction, and business type. This information is provided for educational purposes and should not be considered legal, professional, or compliance advice. Please consult with qualified accessibility consultants and legal professionals before implementing changes to your digital properties.
Stay informed
Accessibility insights delivered
straight to your inbox.


Automate the software work for accessibility compliance, end-to-end.
Empowering businesses with seamless digital accessibility solutions—simple, inclusive, effective.
Book a Demo