// pattern
Accessible Data Table Pattern
A sortable data table using native <table> elements with <caption>, <th scope="col">, sortable column headers with aria-sort, and a live region to announce sort changes. Native HTML does the heavy lifting — ARIA fills the gaps for sort state.
// 01 · live demo
Live Demo
Click Name or Start Date column headers to sort. Use Tab to reach sort buttons, Enter/Space to activate.
| Role | Department | ||
|---|---|---|---|
| Alice Johnson | Senior Engineer | Engineering | 2019-03-15 |
| Bob Martinez | Product Manager | Product | 2020-07-01 |
| Carol Chen | UX Designer | Design | 2021-01-10 |
| David Kim | QA Engineer | Engineering | 2018-11-20 |
| Eva Novak | Tech Lead | Engineering | 2017-06-05 |
| Frank Osei | Content Strategist | Marketing | 2022-04-18 |
| Grace Liu | Data Analyst | Product | 2023-09-02 |
Inaccessible version (div-based):
This version uses <div> elements with CSS grid. No table semantics, no header associations, no caption, no sort controls. Screen readers see a flat list of text with no structure.
// 02 · the code
The Code
<!-- Scroll wrapper for responsive tables -->
<div class="table-wrapper" role="region" aria-label="Team members" tabindex="0">
<table>
<!-- Caption: announced when entering the table -->
<caption>Team Members</caption>
<thead>
<tr>
<!-- Sortable column: button inside th, aria-sort on th -->
<th scope="col" aria-sort="none">
<button type="button" class="sort-btn"
data-sort-key="name">
Name
<svg class="sort-icon" aria-hidden="true">...</svg>
</button>
</th>
<!-- Non-sortable columns -->
<th scope="col">Role</th>
<th scope="col">Department</th>
<!-- Sortable column -->
<th scope="col" aria-sort="none">
<button type="button" class="sort-btn"
data-sort-key="startDate">
Start Date
<svg class="sort-icon" aria-hidden="true">...</svg>
</button>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice Johnson</td>
<td>Senior Engineer</td>
<td>Engineering</td>
<td>2019-03-15</td>
</tr>
<!-- More rows... -->
</tbody>
</table>
<!-- Live region for sort announcements -->
<div class="sr-only" aria-live="polite"
id="sort-announcement"></div>
</div>
/* Responsive horizontal scroll wrapper */
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
caption {
padding: 0.75rem 1.5rem;
font-weight: 700;
font-size: 1rem;
text-align: left;
border-bottom: 1px solid #d1d5db;
}
th {
padding: 0.5rem 1.5rem;
text-align: left;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
border-bottom: 2px solid #d1d5db;
white-space: nowrap;
}
td {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
/* Hover row highlight */
tbody tr:hover {
background: rgba(0, 0, 0, 0.02);
}
tbody tr:last-child td {
border-bottom: none;
}
/* Sort button: fills the entire th */
.sort-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0;
border: none;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.05em;
/* SC 2.5.8: minimum 24x24 target */
min-height: 2.75rem;
width: 100%;
}
.sort-btn:hover {
color: #5b2a86;
}
/* SC 2.4.13: focus indicator with 3:1 contrast */
.sort-btn:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: 2px;
border-radius: 0.25rem;
}
/* Sort icon: chevron rotates for direction */
.sort-icon {
width: 14px;
height: 14px;
transition: transform 0.15s ease;
opacity: 0.4;
flex-shrink: 0;
}
/* Active sort direction indicators */
[aria-sort="ascending"] .sort-icon,
[aria-sort="descending"] .sort-icon {
opacity: 1;
}
[aria-sort="descending"] .sort-icon {
transform: rotate(180deg);
}
/* Sticky header option */
thead {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
}
/* Visually hidden but accessible */
.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;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.sort-icon {
transition: none;
}
}
function initSortableTable(tableWrapper) {
const table = tableWrapper.querySelector('table');
const tbody = table.querySelector('tbody');
const announcement = tableWrapper.querySelector(
'[aria-live="polite"]'
);
const sortButtons = table.querySelectorAll('.sort-btn');
sortButtons.forEach(button => {
button.addEventListener('click', () => {
const th = button.closest('th');
const key = button.dataset.sortKey;
const colIndex = Array.from(
th.parentElement.children
).indexOf(th);
// Determine new sort direction
const currentSort = th.getAttribute('aria-sort');
const newSort = currentSort === 'ascending'
? 'descending'
: 'ascending';
// Reset all sortable columns to "none"
table.querySelectorAll('th[aria-sort]')
.forEach(col => {
col.setAttribute('aria-sort', 'none');
});
// Set new sort direction on this column
th.setAttribute('aria-sort', newSort);
// Sort the rows
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
const aVal = a.children[colIndex].textContent.trim();
const bVal = b.children[colIndex].textContent.trim();
// Check if values are dates (YYYY-MM-DD)
const aDate = Date.parse(aVal);
const bDate = Date.parse(bVal);
if (!isNaN(aDate) && !isNaN(bDate)) {
return newSort === 'ascending'
? aDate - bDate
: bDate - aDate;
}
// String comparison
const compare = aVal.localeCompare(bVal);
return newSort === 'ascending'
? compare
: -compare;
});
// Re-render tbody
rows.forEach(row => tbody.appendChild(row));
// Announce sort change via live region
const colName = button.textContent.trim();
announcement.textContent =
`Sorted by ${colName}, ${newSort}`;
});
});
}
// Initialize all sortable tables
document.querySelectorAll('.table-wrapper')
.forEach(initSortableTable);
<div> with role="region" and aria-label lets screen reader users discover the scrollable area. Adding tabindex="0" makes it keyboard-scrollable for sighted keyboard users.
// 03 · why these decisions
Why These Decisions
Why native <table> instead of divs with CSS grid
Screen readers provide dedicated table navigation commands that only work with real <table> elements. In JAWS and NVDA, users press Ctrl+Alt+Arrow to move between cells, and the screen reader announces the column header as they move. With div-based layouts, these commands do nothing — the user has no way to navigate the data or understand which column a value belongs to. Adding role="table" to a div is technically possible but fragile and unnecessary when the native element exists.
Why <caption>
The <caption> element identifies the table's purpose and is announced automatically when a screen reader enters the table. NVDA announces "Team Members table with 7 rows and 4 columns" — the caption provides the "Team Members" part. Without it, screen readers announce "table with 7 rows and 4 columns" and the user has no idea what the data represents until they start exploring individual cells.
Why scope="col" on <th> elements
The scope attribute explicitly tells the browser and assistive technology whether a header applies to its column or its row. While browsers can usually infer this from the table structure, the explicit attribute removes all ambiguity. When a screen reader user navigates to a cell, it announces the associated column header — but only if the association is correctly established. The scope attribute guarantees this works in all screen readers and table configurations.
Why <button> inside sortable <th>
A <button> inside the header cell provides a clear, keyboard-accessible click target. Making the <th> itself clickable via an onclick handler or JavaScript would require adding tabindex, a role, and keydown event handling manually. Buttons give you all of this for free: they are focusable, they activate on Enter and Space, and they are announced as interactive elements by screen readers.
Why aria-sort
The aria-sort attribute is the only way to communicate the current sort state to screen readers. When a user navigates to a sortable column header, the screen reader announces "sorted ascending" or "sorted descending" along with the header text. Without this attribute, the user has no way to know which column is sorted or in which direction — they would need to read through the data and infer the sort order themselves.
Why a live region for sort announcements
When the table re-sorts, the DOM changes happen in <tbody> — but the user's focus stays on the sort button in <thead>. Screen readers don't automatically announce that the table content has changed. An aria-live="polite" region solves this: when its text content changes, the screen reader announces the new text (e.g., "Sorted by Name, ascending") without moving focus. This gives the user immediate confirmation that their action worked.
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Moves focus to the next sortable column header button |
| Enter / Space | Sorts the table by that column, toggling between ascending and descending |
| Ctrl+Alt+Arrow | Table cell navigation in screen reader mode (JAWS/NVDA). Moves between cells and announces column headers. |
<table>, <th>, and <td> elements. VoiceOver uses VO+Arrow for the same purpose.
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
-
1.3.1 Info and Relationships
Level A
— Semantic table structure with
<table>,<th>,scope, and<caption>conveys data relationships programmatically. - 1.3.2 Meaningful Sequence Level A — Table data reads in a logical order: caption, then headers, then rows from top to bottom, cells from left to right.
- 2.1.1 Keyboard Level A — Sort buttons are keyboard operable via Tab to focus and Enter/Space to activate.
-
4.1.2 Name, Role, Value
Level A
— Sortable column headers expose their sort state via
aria-sort, and sort buttons provide accessible names from their text content. -
2.4.6 Headings and Labels
Level AA
— The
<caption>element describes the table's purpose, and column headers label each data column. - 2.4.13 Focus Appearance Level AAA — Sort buttons have visible focus indicators using a 2px solid outline with 3:1 contrast ratio.
-
2.5.8 Target Size (Minimum)
Level AA
— Sort buttons meet the 24x24px minimum target size via
min-height: 2.75remandwidth: 100%. -
4.1.3 Status Messages
Level AA
— Sort changes are announced via an
aria-live="polite"region without moving focus, so screen reader users know the table has been re-sorted.
// 06 · screen reader behavior
Screen Reader Behavior
When entering the table
- NVDA: "Team Members table with 7 rows and 4 columns" — announces the caption, element type, and dimensions
- JAWS: "Team Members, table with 7 rows and 4 columns" — similar table mode entry announcement
- VoiceOver: "Team Members, table, 4 columns, 7 rows" — caption, type, and dimensions in slightly different order
When navigating to a sortable column header
- NVDA: "Name column 1, sorted ascending, button" — announces header text, column position, sort state, and the button role
- JAWS: "Name, column header, sort ascending" — header text, role, and current sort direction
- VoiceOver: "Name, sort button, sorted ascending" — header text, button role, and sort state
When navigating cells with table commands
- NVDA: Pressing Ctrl+Alt+Down moves to the cell below; announces "Alice Johnson, row 2, Name" — the cell content, row position, and column header
- JAWS: Similar cell-by-cell navigation with header announcements at each position change
- VoiceOver: Uses VO+Arrow for cell navigation; announces cell content and associated headers
After sorting
When a sort button is activated, the live region announces "Sorted by Name, ascending" (or the relevant column and direction). Focus remains on the sort button, and the user can continue navigating the re-sorted table immediately.
// 07 · common mistakes
Common Mistakes
<table>
Screen reader table navigation commands (Ctrl+Alt+Arrow) only work with native <table> elements. A div-based grid layout looks like a table visually but provides no table semantics — screen reader users cannot navigate between cells or hear column headers announced as they move through the data.
<th> or scope attributes
Without <th> elements, screen readers cannot identify column headers. Without scope="col", the association between headers and their data cells becomes ambiguous, especially in complex tables. Users hear cell content without knowing which column it belongs to.
<caption> element
Without a caption, the table has no programmatic label. Screen readers announce "table with 7 rows and 4 columns" but the user has no idea what the table contains until they start exploring individual cells. The caption provides immediate context.
aria-sort
If sortable columns lack the aria-sort attribute, screen reader users have no way to know which column the table is sorted by or in which direction. They would need to read through the data and infer the sort order, which is impractical for large datasets.
<th> clickable without a <button>
Adding a click handler directly to a <th> element makes it mouse-operable but not keyboard-accessible. <th> elements are not focusable by default and do not respond to Enter or Space. A <button> inside the <th> provides all of this natively.
<tbody> changes but focus stays on the sort button. Screen readers do not automatically announce DOM changes outside the focused element. Without an aria-live region, users get no feedback that their sort action worked and the data has been reordered.
// 08 · native html vs. aria
Native HTML vs. ARIA
Data tables are a case where native HTML handles most of the work. ARIA is only needed for sort state and announcements — the table structure itself should always use native elements.
| Feature | Native HTML | ARIA |
|---|---|---|
| Table structure | <table> — automatic screen reader table navigation |
role="table" — unnecessary with native element |
| Column headers | <th scope="col"> — announced during cell navigation |
role="columnheader" — unnecessary with native element |
| Row headers | <th scope="row"> — announces row context |
role="rowheader" — unnecessary with native element |
| Caption | <caption> — announced on table entry |
aria-label on table — use only when caption is not visible |
| Sort state | None — no native HTML sort mechanism | aria-sort="ascending/descending/none" — required |
| Sort announcement | None — no native mechanism for dynamic content changes | aria-live region — required for sort change announcements |
| Cell navigation | Automatic in screen readers with native tables | Works only with real <table> elements or correct ARIA roles |
| Header association | scope attribute — simple column/row association |
headers attribute — for complex tables with spanning cells |
| Data cells | <td> — automatic cell semantics |
role="cell" — unnecessary with native element |
<table>, <th>, <td>, <caption>, and scope for the structure. ARIA only steps in for sort state (aria-sort) and sort announcements (aria-live). If you find yourself adding role="table" or role="cell", you are almost certainly using the wrong HTML elements.