Accessible Row Selection in TanStack Table (React)
Permalink to "Accessible Row Selection in TanStack Table (React)"TanStack Table tracks row selection as a plain object of row ids mapped to true, and offers handlers to toggle it. Everything the user perceives — the checkboxes, their names, the header’s mixed state, the count — is rendered by you. This page maps the selection API to an accessible checkbox column and a count announcement that stays calm during range selection.
It applies the general patterns from implementing select-all with indeterminate checkbox state and announcing selection count changes to TanStack’s API, within bulk selection & batch actions.
Spec reference
Permalink to "Spec reference"TanStack Table v8 selection APIs:
row.getIsSelected(),row.getCanSelect(),row.getToggleSelectedHandler().table.getIsAllRowsSelected(),table.getIsSomeRowsSelected(),table.getToggleAllRowsSelectedHandler()— across all rows.table.getIsAllPageRowsSelected(),table.getIsSomePageRowsSelected(),table.getToggleAllPageRowsSelectedHandler()— the current page only.state.rowSelection—{ [rowId]: true }, andgetRowIdto make those ids stable.
On the HTML side, a native <input type="checkbox"> has three visual states but only two values; the mixed state comes from the indeterminate DOM property, which can only be set from script and maps to aria-checked="mixed" in the accessibility tree. SC 4.1.2 Name, Role, Value covers every checkbox; SC 4.1.3 covers the count.
When to use this approach — and when not to
Permalink to "When to use this approach — and when not to"Use native checkboxes whenever the table is a static <table> with a selection column. They are focusable in normal tab order, operable with Space, and announced correctly everywhere.
If the table is an interactive role="grid" where selection is expressed by aria-selected on rows, the rendering changes — see aria-selected on cells versus rows. Do not mix the two models: a row with both a checkbox and aria-selected is announced as selected twice, sometimes inconsistently.
The misapplication to name is labelling every checkbox “Select row” or “Toggle selected”, which is what most copy-pasted TanStack examples do. A screen reader user tabbing down the column hears the same phrase forty times with nothing to tell rows apart.
Annotated code example
Permalink to "Annotated code example"import { useEffect, useRef, useState } from 'react';
// Header checkbox: the indeterminate property can only be set from script
function IndeterminateCheckbox({ indeterminate, ...rest }) {
const ref = useRef(null);
useEffect(() => { ref.current.indeterminate = !!indeterminate && !rest.checked; },
[indeterminate, rest.checked]); // SC 4.1.2: aria-checked="mixed"
return <input type="checkbox" ref={ref} {...rest} />;
}
const selectColumn = {
id: 'select',
header: ({ table }) => (
<IndeterminateCheckbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
// SC 4.1.2: the label states the scope of "all"
aria-label={`Select all ${table.getRowModel().rows.length} invoices on this page`}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
// SC 4.1.2: name from the row's identifying value
aria-label={`Select invoice ${row.original.id}`}
/>
),
};
// Count announcement: one message after a burst of changes (SC 4.1.3)
function useSelectionAnnouncement(table) {
const [msg, setMsg] = useState('');
const count = Object.keys(table.getState().rowSelection).length;
const total = table.getPrePaginationRowModel().rows.length;
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; }
const t = setTimeout(() => setMsg(
count ? `${count} of ${total} invoices selected.` : 'Selection cleared.'), 400);
return () => clearTimeout(t);
}, [count, total]);
return msg; // render in a <p role="status"> that exists from the first render
}
getRowId: (row) => row.id is required in the table options for this to survive sorting and pagination. Without it, selection keys are row indices, and sorting the table silently moves the selection to different invoices — a data-integrity bug that sighted users may spot and screen reader users will not.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Tab to row checkbox |
“Select invoice INV-1042, checkbox, not checked” | JAWS adds the column header if the checkbox cell has one |
Space |
“checked” … then “3 of 86 invoices selected.” | The count follows the state after the debounce |
Space on header (none selected) |
“checked” … “25 of 86 invoices selected.” | Label already said “on this page” |
| Header with some selected | “Select all 25 invoices on this page, checkbox, half checked” | NVDA “half checked”, JAWS “partially checked”, VoiceOver “mixed” |
| Page change | Header state recomputed for the new page | Announce the carried-over total, not the page count |
Integration context
Permalink to "Integration context"The count feeds the bulk action toolbar described in contextual bulk action toolbars. Keep a single status region for the table and write the count there; if sorting or filtering also announces, combine messages rather than racing two effects.
Shift-click range selection is not built into TanStack; if you add it, it needs a keyboard equivalent, as covered in shift-click range selection with keyboard equivalents.
Gotchas
Permalink to "Gotchas"Selection persisting across filters. TanStack keeps selected ids that the current filter hides. That is usually correct, but the announcement must say so: “3 selected, 1 hidden by the current filter”. Otherwise a bulk delete removes rows the user cannot see.
disabled rows in select-all. getCanSelect() returning false excludes rows from the toggle-all handler. The header label count should use selectable rows, not all rows.
Controlled checkboxes without onChange. React warns, and some wrappers render readOnly checkboxes that screen readers announce as read-only. Always pass the toggle handler.
Design system notes
Permalink to "Design system notes"If your design system wraps TanStack Table, make the selection column a first-class feature of the wrapper rather than a recipe teams copy. The wrapper should require a getRowLabel(row) function so checkbox names cannot fall back to “Select row”, derive the header label from the scope it actually selects, and render the status element itself. Those three decisions are the ones that drift when every team writes its own selection column.
Expose the count message as a formatter prop — (count, total, hidden) => string — so products can localise it, but keep the debounce and the single region inside the component.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Should selection be cleared when the user changes page?
Not by default. Keep the selection across pages, reflect the page’s own state in the header checkbox, and include the overall count in the announcement so users know selections on other pages still exist.
How should row checkboxes in TanStack Table be labelled?
With the row’s identifying value — “Select invoice INV-1042” — using aria-label or a visually hidden label element. A generic “Select row” repeated on every row gives screen reader users no way to tell rows apart.
How do I show an indeterminate select-all checkbox in React?
Set the checkbox element’s indeterminate property from a ref in an effect, driven by getIsSomePageRowsSelected or getIsSomeRowsSelected. There is no HTML attribute for it; the browser maps the property to aria-checked=“mixed”.
Why do selections move to the wrong rows after sorting?
Because TanStack uses the row index as the id by default. Supply getRowId so selection is keyed by a stable record id that survives sorting, filtering and pagination.
Related
Permalink to "Related"- Select-all with indeterminate state — the header checkbox in depth
- Sorting with TanStack Table — the same library’s sort model
- Contextual bulk action toolbars — what the selection feeds into