react nextjs accessibility patterns
Building a Sortable Accessible Data Table in React
Column sorting is the feature that most often turns an otherwise accessible table into a barrier. A header that responds to clicks but is built from a bare <th onClick> is invisible to keyboard users; a sort that reorders rows without exposing the new state or announcing the change leaves screen reader users staring at data that silently rearranged. This guide builds a sortable table that gets all three details right: a real button as the control, aria-sort on the active header, and a polite announcement of the new order.
This is a deep dive under Accessible Data Tables & Grids in React. If you have not yet established semantic table markup, start there—this guide assumes a working <table> with <caption> and <th scope> and focuses solely on making the sort interaction accessible.
Mapped WCAG Success Criteria:
2.1.1 Keyboard(Level A)4.1.2 Name, Role, Value(Level A)4.1.3 Status Messages(Level AA)1.3.1 Info and Relationships(Level A)
Prerequisites
This pattern assumes you already have:
- A native
<table>with a<caption>,<thead>, and<th scope="col">headers, so the column-to-cell relationships required by1.3.1 Info and Relationshipsare in place. - A way to render rows from sorted application state—
useStateplususeMemohere, but the pattern is identical with TanStack Table or any data layer. - A polite live region available in the DOM. We render one inline below, but a shared announcer like the one in Dynamic Content & State Announcements works just as well.
The goal is to add sorting without touching any of the markup that makes the table itself accessible.
A Sortable Header Is a Button Inside a <th>
The control that triggers a sort must be a real <button>, and it must live inside the <th>, not replace it. This keeps the cell's role as a column header (1.3.1) while making the trigger keyboard-operable and correctly exposed as a button per 2.1.1 Keyboard and 4.1.2 Name, Role, Value. A <th onClick> gives you neither—it is not focusable, not in the tab order, and announced as a plain header with no hint that it does anything.
'use client';
import { useMemo, useState } from 'react';
type Dir = 'ascending' | 'descending' | 'none';
type SortState = { key: keyof Row; dir: Exclude<Dir, 'none'> };
const COLUMNS: { key: keyof Row; label: string }[] = [
{ key: 'region', label: 'Region' },
{ key: 'revenue', label: 'Revenue' },
{ key: 'growth', label: 'Growth' },
];
function SortableHeader({
column, sort, onSort,
}: {
column: { key: keyof Row; label: string };
sort: SortState;
onSort: (key: keyof Row) => void;
}) {
const isActive = sort.key === column.key;
// aria-sort belongs on the th so AT reports it as a property of the column
const ariaSort: Dir = isActive ? sort.dir : 'none';
return (
<th scope="col" aria-sort={ariaSort}>
{/* Real button: focusable, in tab order, announced as a button */}
<button type="button" onClick={() => onSort(column.key)}>
{column.label}
{/* Arrow is purely visual; aria-sort already conveys the direction */}
<span aria-hidden="true">
{isActive ? (sort.dir === 'ascending' ? ' ▲' : ' ▼') : ''}
</span>
</button>
</th>
);
}
The visible arrow is wrapped in aria-hidden="true" deliberately. Direction is already exposed through aria-sort; letting a screen reader also read "up arrow" would be redundant and confusing. Convey state to assistive technology through ARIA, and to sighted users through the icon—never duplicate one in the other.
Reflecting Direction With aria-sort
aria-sort is the property that tells assistive technology how a column is currently sorted. It takes one of ascending, descending, none, or other, and—critically—at most one header in the table may have a value other than none at a time. If two headers claim to be the active sort, the user cannot tell which one actually ordered the rows.
The rule is enforced naturally if you derive each header's aria-sort from a single piece of state. In the component above, only the header whose key matches sort.key receives the active direction; every other header computes 'none'. There is no separate bookkeeping to keep in sync.
export function SortableTable({ data }: { data: Row[] }) {
const [sort, setSort] = useState<SortState>({ key: 'region', dir: 'ascending' });
const [message, setMessage] = useState('');
const sorted = useMemo(() => {
const copy = [...data];
copy.sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sort.dir === 'ascending' ? cmp : -cmp;
});
return copy;
}, [data, sort]);
function handleSort(key: keyof Row) {
setSort((prev) => {
// Toggle direction if the same column, else start ascending
const dir: SortState['dir'] =
prev.key === key && prev.dir === 'ascending' ? 'descending' : 'ascending';
const label = COLUMNS.find((c) => c.key === key)!.label;
// Announce the new order — see next section
setMessage(`Table sorted by ${label}, ${dir}.`);
return { key, dir };
});
}
return (
<>
<table>
<caption>Q3 revenue by region (sortable)</caption>
<thead>
<tr>
{COLUMNS.map((col) => (
<SortableHeader key={String(col.key)} column={col} sort={sort} onSort={handleSort} />
))}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={row.id}>
<th scope="row">{row.region}</th>
<td>{row.revenue}</td>
<td>{row.growth}</td>
</tr>
))}
</tbody>
</table>
{/* Polite live region: announces sort changes without stealing focus */}
<div role="status" aria-live="polite" className="sr-only">
{message}
</div>
</>
);
}
Announcing the Sort via a Polite Live Region
Updating aria-sort is not enough on its own. A screen reader only re-reads aria-sort when the user navigates back to the header; immediately after pressing the button, focus stays on the header, so the user may hear nothing about the fact that potentially hundreds of rows just reordered. That silent reorder is exactly what 4.1.3 Status Messages exists to prevent.
The fix is the polite live region in the component above. When the sort changes, we set a message like Table sorted by Revenue, descending. into a role="status" region. Because it is polite, it announces after the user's current speech finishes, without interrupting and without moving focus—the user stays on the header they just activated and hears confirmation of what happened.
Keep the message concise and consistent: column name, then direction. Avoid stuffing in the row count ("...312 rows re-sorted") unless your users have asked for it; verbose announcements on a frequently-used control quickly become noise. If you already centralize announcements with a shared useLiveAnnouncer hook from Dynamic Content & State Announcements, call that instead of rendering a local region—just make sure the region exists in the DOM before the first announcement.
Keyboard Operability
Because the sort trigger is a native <button>, keyboard support is mostly free—but verify each piece against 2.1.1 Keyboard:
- Tab / Shift+Tab reaches every sortable header button in DOM order. There are no skipped or unreachable controls.
- Enter and Space both activate the button and trigger the sort. A native
<button>handles both keys for you; a<div role="button">would force you to wire them manually—another reason to use the real element. - Focus stays on the activated header after sorting, so a keyboard user can press Enter again to toggle direction without hunting for the control. Do not move focus to the top of the table on sort; that would disorient users who were working within a specific column.
If a column is not sortable, render its header as a plain <th scope="col">Label</th> with no button. Do not render a disabled button—an unsortable column simply has no control, which is clearer than a focusable-but-dead one.
How to Verify
Combine an automated pass with manual keyboard and screen reader checks—the automated tools confirm structure, but only manual testing confirms the experience.
- Automated (axe-core / jest-axe): Render the table and assert no violations. axe flags an invalid
aria-sortvalue or a header witharia-sortthat is not acolumnheader, but it cannot tell whether the sort announcement fires—that is a manual check. - Keyboard only: Unplug the mouse. Tab to a header, press Enter, then Space. Confirm the rows reorder, the visible arrow flips, and focus remains on the header. Tab through all headers and confirm none are skipped.
- Screen reader (NVDA + Firefox, VoiceOver + Safari): Navigate to a sortable header and confirm it is announced as a button. Activate it and confirm the polite region speaks "Table sorted by column, direction." Navigate back to the header and confirm the screen reader reads the current sort state from
aria-sort. - State invariant: Inspect the DOM after several sorts and confirm exactly one
<th>carries anaria-sortvalue other thannone.
Testing Hook: A fast regression test is to assert in jest-axe that after a simulated click, exactly one header has
aria-sortset toascendingordescendingand the live region's text content matches the expected message.
Common a11y Mistakes
- Using
<th onClick>instead of a<button>inside the<th>, leaving the sort unreachable and unannounced for keyboard users. - Putting
aria-sorton the button rather than the<th>, where assistive technology does not expect it as a column property. - Leaving
aria-sorton multiple headers at once, so users cannot tell which column actually drove the order. - Relying on
aria-sortalone with no live region, so the reorder is silent until the user happens to revisit the header. - Reading the visual arrow to screen readers by forgetting
aria-hidden="true", duplicating the direction already conveyed byaria-sort. - Moving focus to the top of the table on sort, disorienting keyboard users who were working in a specific column.
Frequently Asked Questions
Why must the sort control be a <button> inside the <th> rather than a clickable <th>?
A bare <th onClick> is not in the tab order and is not announced as interactive, so keyboard and screen reader users cannot operate or even discover it. A native <button> inside the header is focusable, handles Enter and Space automatically, and is announced as a button, satisfying 2.1.1 Keyboard and 4.1.2 Name, Role, Value, while the <th> keeps its role as a column header.
Should aria-sort go on the button or the table header cell?
It belongs on the <th>. aria-sort is a property of the column header, and assistive technology reads it when the user navigates to that header. Placing it on the inner button puts it on the wrong role and may not be reported as the column's sort state.
Why do I need a live region if I already set aria-sort?aria-sort is only re-read when the user navigates back to the header. Right after activating the sort, focus stays on the header and the rows reorder silently. A polite live region announces "Table sorted by column, direction" immediately, satisfying 4.1.3 Status Messages, so users learn the data changed without having to revisit the header.
Should I announce the number of rows that were re-sorted? Usually no. Sorting is a frequent action, and a verbose announcement quickly becomes noise. Keep the message to the column name and direction. Add the row count only if your users specifically need it, and consider making it a user-configurable verbosity preference.