Bulk Selection & Batch Actions

Permalink to "Bulk Selection & Batch Actions"

Letting users pick many rows and act on them at once — delete, export, assign, archive — is a standard grid feature and a standard accessibility failure. The failures are specific: a select-all checkbox that never exposes its half-selected state, a keyboard user who cannot select a range without a mouse drag, a selection count that only exists as a pixel of visual text, and a batch-action toolbar that materialises out of nowhere with no announcement and no reachable focus. This page details the ARIA attributes, the DOM indeterminate property, the keyboard contract, and the announcement strategy that prevent each of those failures.

Before wiring any selection behaviour, the underlying table must be structurally correct. Review semantic HTML table construction so that scope, <caption>, and header association are already sound — selection UI layered onto a malformed table inherits every one of its problems. The selection count relies on aria-live regions for dynamic data, so confirm you can declare and update a live region before starting here.


WCAG Criteria in Scope

Permalink to "WCAG Criteria in Scope"
Criterion Level Relevance to this pattern
1.3.1 Info and Relationships A Selection state and the select-all tri-state must be conveyed programmatically, not by pixel styling
2.1.1 Keyboard A Every selection action — single, range, select-all — must be operable without a pointer
2.4.3 Focus Order A Revealing the batch toolbar must not scramble focus order or strand the user
3.2.2 On Input A Toggling a checkbox must not trigger an unexpected navigation or submit
4.1.2 Name, Role, Value A Each checkbox exposes its name and checked/mixed value; rows using aria-selected expose selection state
4.1.3 Status Messages AA The running selection count must reach assistive technology without moving focus

Prerequisites

Permalink to "Prerequisites"

This page assumes you can:


Selection State Model

Permalink to "Selection State Model"

Bulk selection is a small state machine. Individual row checkboxes feed a selection set; the size of that set relative to the total row count determines the header checkbox’s tri-state; every transition emits one polite announcement and toggles the visibility of the batch toolbar. Getting the order right — derive header state, then announce, then reveal the toolbar — is what keeps the accessibility tree consistent with what the user sees.

Bulk selection state machine and downstream outputs Three selection states across the top — none with an unchecked header box, some with an indeterminate header box, and all with a checked header box — connected by transitions for selecting and clearing rows. Each state feeds a polite live region announcement below, and the some and all states also reveal a batch-action toolbar. NONE header: unchecked 0 of 240 selected SOME header: indeterminate aria-checked="mixed" ALL header: checked 240 of 240 selected select row select last clear one clear last Polite live region (role="status") "3 of 240 rows selected" — announced without moving focus Batch-action toolbar (role="toolbar") revealed when selection > 0; focus managed on show Delete · Export · Assign — each a labelled button

The header checkbox is the only control in a data grid that legitimately needs three states, and the third state — indeterminate — is the one implementations most often skip. It is set through the DOM indeterminate property, not an HTML attribute, which is the single most common bug in this pattern and is covered in depth in implementing select-all with indeterminate checkbox state.


ARIA & HTML Spec Reference

Permalink to "ARIA & HTML Spec Reference"

Native <input type="checkbox"> and the indeterminate property

Permalink to "Native <input type="checkbox"> and the indeterminate property"

A native checkbox has two HTML-visible states: checked present or absent. The third, indeterminate, is a property of the DOM element (el.indeterminate = true), never an attribute you can write in markup. It is purely presentational plus an accessibility-tree signal — the checkbox still submits as either checked or unchecked. Setting it exposes a “mixed” value to assistive technology while keeping the checkbox’s native role and keyboard handling.

aria-checked="mixed" for custom checkboxes

Permalink to "aria-checked="mixed" for custom checkboxes"

If you build a checkbox out of a <div role="checkbox"> or a <button>, there is no indeterminate property to lean on. Use aria-checked="mixed" to expose the half-selected state, alongside aria-checked="true" and aria-checked="false". Prefer the native input whenever possible; you inherit Space toggling, the focus ring, and the OS checkbox styling for free.

aria-selected on rows versus checkboxes

Permalink to "aria-selected on rows versus checkboxes"

aria-selected is a state defined for elements inside composite widgets — role="row" within role="grid" or role="treegrid", and role="option" within role="listbox". It is not valid on a <tr> inside a plain reading-mode <table>. Two decision rules:

  • Reading-mode <table> (users Tab between checkboxes, press Space): use a real <input type="checkbox"> per row. Do not add aria-selected.
  • Composite role="grid"/role="treegrid" (rows are focusable, arrow keys navigate): use aria-selected="true|false" on each role="row". A checkbox may still be present as a visual affordance, but the row’s aria-selected is the authoritative state.

Applying both a checkbox and aria-selected to the same row in a reading-mode table produces duplicated announcements (“checked, selected”) on JAWS and NVDA. Pick one model per grid.

aria-label / aria-labelledby for row checkboxes

Permalink to "aria-label / aria-labelledby for row checkboxes"

Every row checkbox needs a name that identifies its row, not a generic “Select”. “Select row: Ada Lovelace” lets a screen reader user who has navigated away from the row still know what a focused checkbox controls. Reference the row’s key cell with aria-labelledby, or compose an aria-label from the row data.

role="toolbar" and aria-controls on the batch bar

Permalink to "role="toolbar" and aria-controls on the batch bar"

Group the batch actions in a container with role="toolbar" and an aria-label such as “Actions for selected rows”. Point aria-controls at the grid’s id. The toolbar uses its own roving tabindex so that Tab reaches the group once and arrow keys move between the buttons inside it.


Step-by-Step Implementation

Permalink to "Step-by-Step Implementation"

Step 1 — Give every row a labelled checkbox (WCAG 1.3.1, 4.1.2)

Permalink to "Step 1 — Give every row a labelled checkbox (WCAG 1.3.1, 4.1.2)"

Each row gets a checkbox whose accessible name identifies the row. The name comes from the row’s key cell so the control is meaningful even when read out of table context.

<!-- WCAG 4.1.2: each checkbox has a row-specific accessible name -->
<!-- WCAG 1.3.1: selection state is a real form control, not styling -->
<table id="people-grid">
  <caption>Team members — select rows to run a batch action</caption>
  <thead>
    <tr>
      <th scope="col" class="select-col">
        <!-- indeterminate is set in JS, never as an attribute -->
        <input type="checkbox" id="select-all" aria-label="Select all rows on this page" />
      </th>
      <th scope="col">Name</th>
      <th scope="col">Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>
        <!-- aria-labelledby names the checkbox after the row's key cell -->
        <input type="checkbox" class="row-select" aria-labelledby="name-1" />
      </td>
      <th scope="row" id="name-1">Ada Lovelace</th>
      <td>Engineer</td>
    </tr>
    <!-- further rows … -->
  </tbody>
</table>

Step 2 — Derive the header tri-state from the selection set (WCAG 1.3.1, 4.1.2)

Permalink to "Step 2 — Derive the header tri-state from the selection set (WCAG 1.3.1, 4.1.2)"

After any row toggles, recompute how many rows are selected and set the header checkbox to checked, unchecked, or indeterminate. indeterminate must be assigned as a property; checked is set to false while indeterminate so the accessibility tree reports “mixed”, not “checked”.

// WCAG 4.1.2: header checkbox exposes checked / unchecked / mixed
const grid = document.getElementById('people-grid');
const selectAll = document.getElementById('select-all');
const rowBoxes = () => Array.from(grid.querySelectorAll('.row-select'));

function syncHeaderState() {
  const boxes = rowBoxes();
  const total = boxes.length;
  const selected = boxes.filter(b => b.checked).length;

  if (selected === 0) {
    selectAll.checked = false;
    selectAll.indeterminate = false;        // fully unchecked
  } else if (selected === total) {
    selectAll.checked = true;
    selectAll.indeterminate = false;        // fully checked
  } else {
    selectAll.checked = false;              // must be false for "mixed"
    selectAll.indeterminate = true;         // DOM property → aria mixed
  }
  return { selected, total };
}

Step 3 — Wire the select-all toggle (WCAG 2.1.1, 3.2.2)

Permalink to "Step 3 — Wire the select-all toggle (WCAG 2.1.1, 3.2.2)"

Activating the header checkbox sets every row on the current page to its state. It must not navigate or submit — only toggle.

// WCAG 2.1.1: operable via Space; WCAG 3.2.2: no context change
selectAll.addEventListener('change', () => {
  const shouldSelect = selectAll.checked;   // browser has already flipped it
  rowBoxes().forEach(box => { box.checked = shouldSelect; });
  selectAll.indeterminate = false;          // an explicit toggle is never mixed
  const { selected, total } = syncHeaderState();
  announceSelection(selected, total);       // Step 5
  updateToolbar(selected);                  // Step 6
});

Step 4 — Support keyboard range selection (WCAG 2.1.1)

Permalink to "Step 4 — Support keyboard range selection (WCAG 2.1.1)"

Range selection cannot be mouse-only. Track an anchor — the last row the user toggled — and select the contiguous range to the current row on Shift+click or Shift+Space. Shift+Arrow extends the range one row at a time.

// WCAG 2.1.1: Shift range selection reachable without a pointer
let anchorIndex = null;

function toggleRange(fromIdx, toIdx, checked) {
  const boxes = rowBoxes();
  const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
  for (let i = lo; i <= hi; i++) boxes[i].checked = checked;
}

grid.querySelector('tbody').addEventListener('click', e => {
  const box = e.target.closest('.row-select');
  if (!box) return;
  const boxes = rowBoxes();
  const idx = boxes.indexOf(box);

  if (e.shiftKey && anchorIndex !== null) {
    e.preventDefault();
    // extend selection from the anchor to the clicked row
    toggleRange(anchorIndex, idx, box.checked);
  }
  anchorIndex = idx;                         // remember for the next Shift action

  const { selected, total } = syncHeaderState();
  announceSelection(selected, total);
  updateToolbar(selected);
});

Step 5 — Announce the running count via a polite live region (WCAG 4.1.3)

Permalink to "Step 5 — Announce the running count via a polite live region (WCAG 4.1.3)"

A role="status" region reports the settled count. Debounce it so a dragged range selecting forty rows produces one announcement, not forty. The full phrasing and debouncing rules live in announcing selection count changes in data grids.

<!-- WCAG 4.1.3: status region declared in static HTML, empty on load -->
<div id="selection-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
// WCAG 4.1.3: announce the settled count without moving focus
let announceTimer;
function announceSelection(selected, total) {
  clearTimeout(announceTimer);
  announceTimer = setTimeout(() => {
    const region = document.getElementById('selection-status');
    region.textContent = '';               // clear so identical values re-speak
    requestAnimationFrame(() => {
      region.textContent = `${selected} of ${total} rows selected`;
    });
  }, 250);                                  // collapse rapid range selects
}

Step 6 — Reveal the batch toolbar and manage focus (WCAG 2.4.3, 4.1.2)

Permalink to "Step 6 — Reveal the batch toolbar and manage focus (WCAG 2.4.3, 4.1.2)"

The toolbar appears when the selection is non-empty. Do not display:none it in a way that removes it from the accessibility tree while focus is inside it. When the last selection is cleared, move focus back to a stable element before hiding the toolbar.

<!-- WCAG 4.1.2: role=toolbar with a descriptive group name -->
<div id="batch-toolbar" role="toolbar" aria-label="Actions for selected rows"
     aria-controls="people-grid" hidden>
  <button type="button" data-action="export">Export</button>
  <button type="button" data-action="assign">Assign</button>
  <button type="button" data-action="delete">Delete</button>
</div>
// WCAG 2.4.3: toolbar reveal keeps focus order predictable
const toolbar = document.getElementById('batch-toolbar');
function updateToolbar(selected) {
  const wasHidden = toolbar.hidden;
  if (selected > 0) {
    toolbar.hidden = false;                 // now in the tree and tab order
  } else {
    // if focus is inside the toolbar, move it out before hiding
    if (toolbar.contains(document.activeElement)) {
      document.getElementById('select-all').focus();
    }
    toolbar.hidden = true;
  }
  // do NOT steal focus into the toolbar on first reveal; let the count
  // announcement (Step 5) tell the user the toolbar is available
  return wasHidden;
}

Keyboard Interaction Contract

Permalink to "Keyboard Interaction Contract"
Key Context Action Expected AT announcement Failure indicator
Space Focus on a row checkbox Toggles that row’s selection “checked, Select row: Ada Lovelace” + “3 of 240 rows selected” Checkbox visual flips but count is silent
Space Focus on header checkbox Selects/deselects all page rows “checked, Select all rows on this page” + “240 of 240 rows selected” Header toggles but rows do not follow
Shift+Space Focus on a row checkbox Selects range from anchor to this row “40 of 240 rows selected” (once) Only the focused row toggles; no range
Shift+Arrow Down/Up Focus in the grid Extends selection one row Updated count after the range settles Focus moves but selection does not extend
Tab After the grid Moves focus into the batch toolbar (if shown) “Actions for selected rows, toolbar” Toolbar skipped or focus lost
Arrow Left/Right Inside the toolbar Moves between action buttons “Export, button” → “Delete, button” Each button is a separate tab stop
Escape Inside the toolbar Returns focus to the grid Focus lands on the last active checkbox Focus dropped to document.body

Screen Reader Compatibility Matrix

Permalink to "Screen Reader Compatibility Matrix"
AT + Browser Header indeterminate state Row checkbox name Live count region Known deviation
NVDA 2024 + Firefox Announces “half checked” / “partially checked” Reads aria-labelledby row name role="status" fires reliably May re-read the count if aria-atomic is omitted
NVDA 2024 + Chrome Announces “partially checked” Reads composed label Live region fires with a slight delay Ensure textContent='' before writing
JAWS 2024 + Chrome Announces “mixed” Reads aria-label on the checkbox role="status" announces full string If header uses aria-checked=mixed without a role, JAWS ignores it
VoiceOver + Safari (macOS) Announces “dimmed” / “mixed” checkbox Reads the label; verify it includes the row key Fires but is suppressed while VO is speaking Keep the label short so it is not truncated
VoiceOver + Chrome (iOS) Announces “mixed” on double-tap focus Reads composed name May fire the count twice Use the rAF clear pattern in Step 5
TalkBack + Chrome (Android) Announces “partially checked” Reads the label Live region fires after current utterance Touch users may miss that Shift range exists; provide an on-screen “select range” affordance

Edge Cases & Failure Modes

Permalink to "Edge Cases & Failure Modes"

1. indeterminate set as an attribute

Permalink to "1. indeterminate set as an attribute"

Writing <input type="checkbox" indeterminate> in HTML does nothing — there is no such attribute. The state exists only as the DOM property el.indeterminate. Frameworks that diff attributes will never toggle it for you; set it imperatively after render. This is the defining trap of the pattern, dissected in implementing select-all with indeterminate checkbox state.

2. Ambiguous “select all” scope in paginated grids

Permalink to "2. Ambiguous “select all” scope in paginated grids"

When the grid is paginated or virtualized, the header checkbox usually selects only the loaded rows, but users assume it selects the entire dataset. A batch delete then removes 25 rows when the user believed they had selected 4,000, or vice versa. Announce the scope explicitly (“All 25 rows on this page selected”) and, when the dataset is larger, offer a distinct “Select all N matching rows” control. For virtualized contexts, pair this with the positional metadata described in accessible virtualized list patterns.

3. Live region flooding during a range drag

Permalink to "3. Live region flooding during a range drag"

A Shift+click across a long range fires one change per row. Without the debounce in Step 5, VoiceOver queues every intermediate count and reads them for several seconds after the user stops. The clearTimeout + requestAnimationFrame pattern collapses the burst into a single settled announcement.

4. Focus trapped or lost when the toolbar hides

Permalink to "4. Focus trapped or lost when the toolbar hides"

If the user clears the last selection while focus sits on a toolbar button, hiding the toolbar drops focus to document.body and the keyboard user is stranded. Always test toolbar.contains(document.activeElement) and move focus to a stable element before setting hidden.

5. Selection lost after sort or filter

Permalink to "5. Selection lost after sort or filter"

Re-sorting or filtering re-renders <tr> nodes and, in a naive implementation, discards the selection set. Persist selection by a stable row key (not DOM position) so a row stays selected after it moves. This intersects directly with sortable and filterable data grids — apply sort state changes and selection re-mapping in the same update.


Announcing Selection Count Changes

Permalink to "Announcing Selection Count Changes"

The running count — “3 of 240 rows selected” — is the primary feedback a screen reader user gets that a selection happened at all. The phrasing, the debounce interval, and the choice of a polite (never assertive) region are covered in the dedicated reference: announcing selection count changes in data grids.

Key facts from that page:

  • Use role="status" (implicit aria-live="polite") with aria-atomic="true" so the full count string is read as one unit.
  • Debounce the announcement 200–300 ms so range selection produces a single settled count.
  • Include both the selected count and the total (“3 of 240”) so the user has scale, and name the scope when it is a page rather than the whole dataset.

Implementing Select-All with Indeterminate State

Permalink to "Implementing Select-All with Indeterminate State"

The header checkbox’s third state is the hardest part of this pattern to get right, because indeterminate is a DOM property rather than an attribute and because custom checkboxes need aria-checked="mixed" instead. The tri-state logic, the keyboard behaviour, and how each screen reader announces “mixed” are covered in implementing select-all with indeterminate checkbox state.

Key facts from that page:

  • Set el.indeterminate = true in JavaScript; it cannot be expressed in markup and must be re-applied after every framework render.
  • While indeterminate, keep checked = false so the accessibility tree reports “mixed”, not “checked”.
  • Clicking an indeterminate checkbox resolves it to fully checked in most browsers; decide and document whether that matches your product’s mental model.

Testing Checklist

Permalink to "Testing Checklist"

Automated

Permalink to "Automated"

Keyboard-only

Permalink to "Keyboard-only"

Screen reader manual

Permalink to "Screen reader manual"

Permalink to "Related"

Back to Accessible Data Tables & Grid Systems