Accessible Sorting With TanStack Table
Permalink to "Accessible Sorting With TanStack Table"TanStack Table is a headless library: it computes sorted rows and exposes sort state, and renders nothing. That makes it a good base for an accessible table — there is no inaccessible markup to fight — but it also means every piece of the accessibility contract is yours to render. The library will happily sort rows under a <div onClick> with no role, no state and no announcement.
This page maps TanStack’s sorting API onto the accessible sortable-header pattern: which API call feeds aria-sort, where the button goes, how to handle multi-sort, and when to announce. It builds on building an accessible sortable table in React and uses the wording from multi-column sort announcement patterns.
Spec reference
Permalink to "Spec reference"The relevant TanStack APIs (v8) are:
header.column.getCanSort()— whether this column is sortable.header.column.getIsSorted()—false,'asc'or'desc'.header.column.getSortIndex()— position in a multi-sort,-1when unsorted.header.column.getToggleSortingHandler()— a handler that toggles sort, and adds to a multi-sort when the event hasshiftKeyset (configurable throughisMultiSortEvent).table.getState().sorting— the array of{ id, desc }objects.
On the ARIA side, aria-sort belongs on the columnheader (<th>) and takes ascending, descending, other or none. ARIA 1.2 says authors should apply it to only one header at a time, which is the key constraint for multi-sort. SC 4.1.2 Name, Role, Value covers the button and header state; SC 4.1.3 Status Messages covers the announcement.
When to use this approach — and when not to
Permalink to "When to use this approach — and when not to"Use it whenever TanStack Table renders to a native <table> for reading, with sort controls in the header. That is the common case and it keeps table navigation commands working.
If your TanStack table is rendered as an interactive role="grid" with cell focus, the sort button still lives in the header cell, but you also need roving focus across cells; the grid-specific parts are in implementing roving tabindex for custom data grids.
The misapplication to call out is spreading getToggleSortingHandler() onto the <th> itself, which is what many examples in the wild do. A clickable header cell is not focusable, has no button role, and cannot be operated with Enter or Space. It fails SC 2.1.1 Keyboard outright.
Annotated code example
Permalink to "Annotated code example"import { useEffect, useRef, useState } from 'react';
import {
useReactTable, getCoreRowModel, getSortedRowModel, flexRender,
} from '@tanstack/react-table';
const dirWord = { asc: 'ascending', desc: 'descending' };
export function InvoiceTable({ data, columns }) {
const [sorting, setSorting] = useState([]);
const [message, setMessage] = useState('');
const first = useRef(true);
const table = useReactTable({
data, columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
// keyboard equivalent for multi-sort: Shift+Enter on the button
isMultiSortEvent: (e) => e.shiftKey,
});
// SC 4.1.3: announce after React has committed the new row order
useEffect(() => {
if (first.current) { first.current = false; return; }
if (!sorting.length) { setMessage('Sort cleared.'); return; }
const label = (id) => table.getColumn(id).columnDef.header;
const [p, ...rest] = sorting;
let msg = `Sorted by ${label(p.id)}, ${p.desc ? 'descending' : 'ascending'}`;
rest.forEach((s) => { msg += `, then by ${label(s.id)}, ${s.desc ? 'descending' : 'ascending'}`; });
setMessage(msg + '.');
}, [sorting]);
return (
<>
<table>
<caption>Open invoices</caption>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => {
const s = h.column.getIsSorted();
const primary = h.column.getSortIndex() === 0;
return (
<th key={h.id} scope="col"
// SC 1.3.1 / 4.1.2: aria-sort only on the primary key
aria-sort={s && primary ? dirWord[s] : undefined}>
{h.column.getCanSort() ? (
// SC 2.1.1: a real button; Enter, Space and Shift+Enter work
<button type="button" onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
<span aria-hidden="true">{s === 'asc' ? ' ▲' : s === 'desc' ? ' ▼' : ''}</span>
</button>
) : flexRender(h.column.columnDef.header, h.getContext())}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((r) => (
// row.id is stable across sorts, so React moves rows rather than remounting
<tr key={r.id}>
{r.getVisibleCells().map((c) => (
<td key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
{/* SC 4.1.3: rendered once, before any message is written */}
<p role="status" className="visually-hidden">{message}</p>
</>
);
}
Two details carry most of the weight. The status element is rendered on mount and only its text changes, which is what makes a live region reliable (see creating live regions before content changes). And isMultiSortEvent reads shiftKey from the click event, which a keyboard-activated button also produces when Shift is held — so Shift+Enter is a real keyboard equivalent for shift-click, not an accident.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Enter on “Amount” |
“Sorted by Amount, ascending.” | NVDA also re-reads “sorted ascending” from the header |
Shift+Enter on “Customer” |
“Sorted by Amount, ascending, then by Customer, ascending.” | JAWS may truncate very long messages at its buffer size |
| Third toggle on primary | “Sort cleared.” (with enableSortingRemoval) |
Some users expect a third state; document it in the column help |
| Arrow into header cell | “Amount, sorted ascending, column header” | Secondary keys are not announced on navigation — by design |
Integration context
Permalink to "Integration context"TanStack’s getSortedRowModel sorts on the column’s accessor value, which is exactly right for formatted numbers — sort on the raw value, render the formatted string, as described in formatting numbers and units in table cells.
When you pair sorting with TanStack’s filtering, keep one status region for both and combine the messages (“12 results, sorted by Amount, descending”) rather than letting two effects write competing messages in the same tick.
Gotchas
Permalink to "Gotchas"Server-side sorting with manualSorting. The effect fires when the sorting state changes, which is before the server has responded. Key the announcement on the data arriving instead, or you announce a sort the table does not yet show.
Unstable row ids. The default row.id is the index, which changes on every sort and makes React remount every row. Pass getRowId: (r) => r.id so the key is stable.
Descending first. sortDescFirst flips the toggle order for numeric columns. That is fine, but the status message must read the actual state, not assume the first press is ascending.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"How do I test TanStack Table sorting accessibility automatically?
Render the table, click or press Enter on a header button, then assert the aria-sort value on the header cell and the text of the status element. An axe scan of the sorted state catches misplaced attributes, and a Playwright keyboard test confirms Enter and Shift+Enter work.
Is TanStack Table accessible?
The library is headless, so it is neither accessible nor inaccessible — it renders nothing. Accessibility depends entirely on the markup you write around its state: a native table, buttons in sortable headers, aria-sort on the primary sorted column, and a status message after each change.
How do I make multi-column sort accessible in TanStack Table?
Keep the default shiftKey check in isMultiSortEvent so Shift+Enter on a header button adds a secondary key, set aria-sort only on the column with sort index 0, and describe the full sort order in the status message.
Should the whole header cell be clickable?
Put the handler on a native button inside the th instead. A clickable th is not focusable and has no button role, so keyboard and screen reader users cannot operate it.
Related
Permalink to "Related"- Sortable table in React — the hand-rolled version of this component
- Multi-column sort announcements — wording for secondary keys
- Row selection in TanStack Table — the same library’s selection model