Accessibility
ListTable generates accessible, semantic HTML tables that work well with screen readers and other assistive technologies.
Semantic HTML
The component uses proper semantic HTML elements without ARIA attributes (which aren't needed for standard tables):
<table>
<caption>Accessible table description</caption>
<thead>
<tr>
<th scope="col">Column Header</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Row Header</th>
<td>Data Cell</td>
</tr>
</tbody>
</table>
Table captions
Always provide a caption property to give context to screen reader users:
<ListTable caption="Quarterly sales report">
- - Q1
- Q2
- Q3
- - $100k
- $120k
- $140k
</ListTable>
Screen readers announce: "Quarterly sales report, table with 2 rows and 3 columns"
Header scope
The component automatically assigns correct scope attributes.
Column headers
Use headerRows to create column headers with scope="col":
<ListTable headerRows={1}>
- - Product
- Price
- - Widget
- $10
</ListTable>
Generates:
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Price</th>
</tr>
</thead>
Row headers
Use headerColumns to create row headers with scope="row":
<ListTable headerColumns={1}>
- - Widget
- $10
- - Gadget
- $20
</ListTable>
Generates:
<tbody>
<tr>
<th scope="row">Widget</th>
<td>$10</td>
</tr>
</tbody>
Both row and column headers
Combine both for complex tables:
<ListTable headerRows={1} headerColumns={1}>
- - Product
- Q1
- Q2
- - Widget
- $10
- $12
- - Gadget
- $20
- $25
</ListTable>
The top-left cell becomes a header for both row and column headers.
Merged cells
Screen readers correctly announce merged cells with rowspan/colspan:
<ListTable headerRows={1}>
- - Month
- [c2] Revenue
- _
- - January
- Sales
- Services
</ListTable>
Screen reader announcement: "Revenue, column header, spans 2 columns"
Testing with screen readers
The component has been tested with:
NVDA (Windows)
- Tables navigated correctly with Ctrl+Alt+Arrow keys
- Headers announced properly
- Merged cells span info provided
- Caption read on table entry
JAWS (Windows)
- Table navigation works as expected
- Row/column context maintained
- Proper header association
VoiceOver (macOS)
- VO+Arrow keys navigate correctly
- Headers announced with cells
- Table summary provided
VoiceOver (iOS)
- Swipe gestures work properly
- Headers read with content
- Table context provided
Keyboard navigation
Standard table keyboard navigation works:
- Tab - Move between interactive elements
- Arrow keys - Navigate cells (screen reader specific)
- Home/End - Jump to row/table boundaries (screen reader specific)
WCAG compliance
The component follows WCAG 2.1 Level AA guidelines:
1.3.1 info and relationships (level A)
- Semantic markup conveys structure
- Table relationships programmatically determined
- Headers properly associated with data cells
1.3.2 meaningful sequence (level A)
- Reading order follows visual layout
- Nested lists parse to logical table structure
4.1.1 parsing (level A)
- Valid HTML table structure
- No duplicate IDs
- Proper nesting of elements
4.1.2 name, role, value (level A)
- Native HTML semantics (no custom roles needed)
- Table, row, cell roles implicit
- Headers have accessible names
Color and contrast
The component renders unstyled tables. When adding CSS:
Text contrast
Ensure text meets minimum contrast ratios:
- Normal text: 4.5:1 minimum
- Large text (18pt+): 3:1 minimum
Focus indicators
Provide visible focus styles for keyboard users:
.my-table td:focus,
.my-table th:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
Don't rely on color alone
Use additional visual cues beyond color:
<!-- Good: Icon + color -->
<ListTable>
- - Status
- Value
- - ✓ Success
- 100%
- - ✗ Failed
- 0%
</ListTable>
<!-- Bad: color only (not in MDX) -->
Best practices
Do
- Always provide a
captionfor context - Use
headerRowsfor column headers - Use
headerColumnsfor row headers - Keep tables simple when possible
- Test with actual screen readers
Don't
- Don't use tables for layout (use CSS Grid/Flexbox)
- Don't nest tables unnecessarily
- Don't omit headers for data tables
- Don't use empty header cells (fails axe audit)
- Don't rely on visual formatting alone
Automated testing
The component passes axe-core accessibility audits:
import { axe } from 'jest-axe';
test('table has no accessibility violations', async () => {
const { container } = render(<ListTable {...props} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
All built-in examples pass axe-core with zero violations.
Complex tables
For very complex tables with multiple header levels:
Use headerRows for stacked headers
<ListTable headerRows={2}>
- - Product
- [c2] Q1
- _
- - _
- Jan
- Feb
- - Widget
- 10
- 12
</ListTable>
Screen readers navigate this correctly with proper scope associations.
Consider simplifying
If a table has more than 2 levels of headers, consider:
- Splitting into multiple simpler tables
- Using a different visualization (chart, list)
- Providing a text summary alongside the table
Screen reader specific features
Table summary
Screen readers announce table dimensions:
"Table with 5 rows and 3 columns"
Navigate by header
Screen readers can jump between headers:
- T key (NVDA/JAWS) - Jump to next table
- Ctrl+Alt+Arrow (NVDA) - Navigate table cells
- VO+Arrow (VoiceOver) - Navigate table cells
Reading strategy
Screen readers offer multiple reading modes:
- Read all content sequentially
- Navigate cell by cell
- List all headers
- Jump to specific row/column
Common accessibility issues
Empty headers
Don't leave header cells empty:
<!-- Bad -->
<ListTable headerRows={1} headerColumns={1}>
- - ← Empty header
- Column 1
- - Row 1
- Data
</ListTable>
<!-- Good -->
<ListTable headerRows={1} headerColumns={1}>
- - Category
- Column 1
- - Row 1
- Data
</ListTable>
Empty headers fail axe-core audits.
Missing caption
Provide context with captions:
<!-- Bad: No context -->
<ListTable>
- - Q1
- Q2
- - $100k
- $120k
</ListTable>
<!-- Good: Clear context -->
<ListTable caption="Quarterly revenue (in thousands)">
- - Q1
- Q2
- - $100k
- $120k
</ListTable>
Layout tables
Don't use ListTable for layout. Use CSS instead.