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 }, and getRowId to 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.

Which select-all API to use Decision tree for choosing between TanStack's all-rows and page-rows select-all handlers depending on whether the table paginates and whether actions apply to unseen rows. Which select-all API to useIs the table paginated, and can bulk actions apply torows the user has not seen?Not paginatedgetToggleAllRowsSelectedHandlerlabel: "Select all 86 invoices"Paginated, page onlygetToggleAllPageRowsSelectedHandlerlabel: "Select all 25 on this page"Paginated, everythingpage first, then offer all"Select all 1,240 invoices" button
The label must say which one you chose — "Select all 1,240" and "Select all on this page" are different promises.

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
Announcements during a ten-row range selection Bar chart comparing how many status messages are produced when ten rows are selected in quick succession, with no debounce, a 150 ms debounce and a 400 ms debounce. Announcements during a ten-row range selectionNo debounce10 messages — each interrupts the last150 ms debounce3 messages400 ms debounce1 messages
A debounce of a few hundred milliseconds turns ten competing messages into one.

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.

From Space bar to spoken count Flow of one checkbox toggle in TanStack Table: Space on the checkbox, rowSelection state update, re-render with the new header state, debounce, and one status message. From Space bar to spoken countSpacenative checkboxtogglesrowSelectionTanStack stateupdatesRe-renderheader mixed staterecomputedDebounce400 ms of quietStatus"3 of 86 invoicesselected."
The checkbox speaks its own state instantly; the count follows once the burst of changes settles.

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.

Permalink to "Related"

← Back to Bulk Selection & Batch Actions