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, -1 when unsorted.
  • header.column.getToggleSortingHandler() — a handler that toggles sort, and adds to a multi-sort when the event has shiftKey set (configurable through isMultiSortEvent).
  • 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.

Mapping TanStack state to ARIA Table mapping TanStack sort state values to the aria-sort value to render and the announcement to make. Mapping TanStack state to ARIATanStack statearia-sort on thStatus messagegetIsSorted() falseomit attributenone for this column'asc', sort index 0ascendingSorted by Amount, ascending'desc', sort index 0descendingSorted by Amount, descending'asc', sort index 1omit attribute…then by Customer, ascendingsorting array emptyomit on allSort cleared
Only the primary key (sort index 0) gets an aria-sort value; the rest is described in words.

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
Where each concern lives Vertical stack showing the TanStack state layer, the rendered header markup, and the status region, with the accessibility responsibility of each. Where each concern livesTanStack sorting statethe array of id and desc pairs, updated by the toggle handlerLibrary's jobHeader renderingth with aria-sort for the primary key; a native button insideYour job: SC 4.1.2Status regionone polite element, text set in an effect after the sort commitsYour job: SC 4.1.3
The library owns the order; you own the semantics and the speech.

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.

Server-side sorting and when to speak Timeline of a manualSorting sort: button press, sorting state change, request sent, response arrives, rows render, announcement. Server-side sorting and when to speakEnter on headertoggle handler runssorting statechanges immediatelyRequest senttable still shows old orderResponsenew rows arriveRender + announce"Sorted by Amount, descending."a server-sorted table, left to right
With manualSorting, the state changes long before the rows do — key the message on the data, not the state.

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.

Permalink to "Related"

← Back to Sortable & Filterable Data Grids