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:
- Build a sound table structure per semantic HTML table construction — correct
scope, header cells, and a<caption>. - Declare and update a polite status region as described in aria-live regions for dynamic data, including why the region must exist in static HTML before you write to it.
- Store and restore a focus reference across a DOM change, covered in focus management in single-page apps — the batch toolbar reveal depends on it.
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.
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, pressSpace): use a real<input type="checkbox">per row. Do not addaria-selected. - Composite
role="grid"/role="treegrid"(rows are focusable, arrow keys navigate): usearia-selected="true|false"on eachrole="row". A checkbox may still be present as a visual affordance, but the row’saria-selectedis 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"(implicitaria-live="polite") witharia-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 = truein JavaScript; it cannot be expressed in markup and must be re-applied after every framework render. - While indeterminate, keep
checked = falseso 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"Related
Permalink to "Related"- Announcing selection count changes in data grids — polite live region phrasing and debouncing for the running count
- Implementing select-all with indeterminate checkbox state — the tri-state header checkbox and
aria-checked="mixed" - Semantic HTML table construction — the structural foundation selection UI depends on
- Sortable & filterable data grids — preserving selection across sort and filter re-renders
- ARIA live regions for dynamic data — declaring and updating the status region that carries the count
- Focus management in single-page apps — storing and restoring focus around the batch toolbar reveal