Implementing Select-All with Indeterminate Checkbox State
Permalink to "Implementing Select-All with Indeterminate Checkbox State"A select-all checkbox has three states, not two: every row selected (checked), no row selected (unchecked), and some rows selected (indeterminate). The indeterminate state is the one that breaks in practice, because it is a DOM property rather than an HTML attribute and disappears on every framework render. This page shows how to drive the tri-state correctly, how to expose it as aria-checked="mixed" when you cannot use a native input, and how each screen reader announces the half-selected state. It is the header-checkbox half of the bulk selection and batch actions pattern; the running count that accompanies it is covered in announcing selection count changes in data grids.
Spec reference
Permalink to "Spec reference"Two distinct mechanisms express the third state, depending on whether the control is a native checkbox or a custom widget.
Native <input type="checkbox">. The HTML specification defines an indeterminate IDL attribute — a property on the DOM element, reachable only as element.indeterminate. There is no content attribute; <input type="checkbox" indeterminate> in markup is ignored. When the property is true, the control paints a dash instead of a tick and reports a “mixed” checked state to the accessibility tree, but its submitted value is still governed by checked (true or false). The three visual/AT states map to two properties:
checked |
indeterminate |
Visual | Accessibility tree |
|---|---|---|---|
false |
false |
Empty box | “not checked” |
true |
false |
Tick | “checked” |
false |
true |
Dash | “mixed” / “partially checked” |
Custom role="checkbox". ARIA defines aria-checked with three token values: true, false, and mixed. A <div role="checkbox" aria-checked="mixed"> exposes the same tri-state, but you must supply the tabindex, the Space/Enter handling, and the focus styling yourself.
WCAG 2.2 criteria in scope: 4.1.2 Name, Role, Value (Level A) — the header control’s mixed state must be programmatically exposed — and 1.3.1 Info and Relationships (Level A) — the relationship between the header and the rows it governs must be conveyed by semantics, not styling alone.
When to use vs. when NOT to
Permalink to "When to use vs. when NOT to"Use the indeterminate state on a select-all control whenever the selection is a strict, non-empty subset of the rows — some but not all. It is the only honest signal that “select all” is currently partial.
Use aria-checked="mixed" only when a native checkbox is genuinely unusable (a design system that cannot style native inputs, for example). Prefer the native input.
Do NOT:
- Write
indeterminateas an attribute. It does nothing. Set the property in JavaScript. - Leave
checkedtrue while indeterminate. Some AT and browsers will announce “checked” and hide the mixed state. Forcechecked = falsewheneverindeterminate = true. - Use indeterminate to mean “disabled” or “unknown”. It has one meaning in this pattern: a partial selection. Overloading it confuses users who have learned the dash convention.
- Forget to re-apply it after render. A virtual-DOM diff that reconciles attributes will silently reset the property to false.
Annotated code example
Permalink to "Annotated code example"Native checkbox — the recommended path
Permalink to "Native checkbox — the recommended path"<!-- WCAG 4.1.2: header checkbox exposes name + tri-state -->
<!-- No "indeterminate" attribute exists; it is set in JS below -->
<th scope="col" class="select-col">
<input type="checkbox" id="select-all"
aria-label="Select all rows on this page" />
</th>
// WCAG 4.1.2: derive the header's three states from the selection set
const selectAll = document.getElementById('select-all');
function syncSelectAll(selectedCount, totalCount) {
if (selectedCount === 0) {
selectAll.checked = false;
selectAll.indeterminate = false; // empty box → "not checked"
} else if (selectedCount === totalCount) {
selectAll.checked = true;
selectAll.indeterminate = false; // tick → "checked"
} else {
selectAll.checked = false; // MUST be false so AT says "mixed"
selectAll.indeterminate = true; // dash → "partially checked"
}
}
// Clicking the header resolves the tri-state to a deliberate two-state action.
// We decide: a click always SELECTS ALL unless everything is already selected.
selectAll.addEventListener('change', () => {
// The browser cleared indeterminate and set checked=true on click.
const selectEverything = selectAll.checked; // honour the new value
document.querySelectorAll('.row-select')
.forEach(box => { box.checked = selectEverything; });
selectAll.indeterminate = false; // an explicit toggle is never mixed
});
Custom checkbox — when native is impossible
Permalink to "Custom checkbox — when native is impossible"<!-- WCAG 4.1.2: role + aria-checked supply the tri-state manually -->
<span id="select-all-custom"
role="checkbox"
tabindex="0"
aria-checked="false"
aria-label="Select all rows on this page"></span>
// WCAG 4.1.2 / 2.1.1: re-implement state + keyboard for a custom checkbox
const custom = document.getElementById('select-all-custom');
function syncCustom(selectedCount, totalCount) {
let state = 'false';
if (selectedCount === totalCount && totalCount > 0) state = 'true';
else if (selectedCount > 0) state = 'mixed'; // ARIA mixed = partial
custom.setAttribute('aria-checked', state);
}
custom.addEventListener('keydown', e => {
// WCAG 2.1.1: Space toggles, matching native checkbox behaviour
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
const selectEverything = custom.getAttribute('aria-checked') !== 'true';
document.querySelectorAll('.row-select')
.forEach(box => { box.checked = selectEverything; });
custom.setAttribute('aria-checked', String(selectEverything));
}
});
Re-applying the property after a framework render
Permalink to "Re-applying the property after a framework render"// React: the indeterminate property is not a JSX attribute — set it in a ref.
// useLayoutEffect runs synchronously after DOM mutation, before paint,
// so the accessibility tree is correct on the same frame (WCAG 4.1.2).
function SelectAllCheckbox({ selected, total }) {
const ref = React.useRef(null);
React.useLayoutEffect(() => {
if (!ref.current) return;
ref.current.checked = selected === total && total > 0;
ref.current.indeterminate = selected > 0 && selected < total;
}, [selected, total]); // re-apply on every selection change
return (
<input type="checkbox" ref={ref}
aria-label="Select all rows on this page" />
);
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Action | Expected AT announcement | Failure indicator |
|---|---|---|---|
Space on header (native) |
Toggles all rows on/off | “checked” or “not checked, Select all rows on this page” | Announces “mixed” after an explicit toggle |
| Some rows selected | Header enters indeterminate | “partially checked” (NVDA) / “mixed” (JAWS) / “dimmed” (VoiceOver) | Announces “not checked”; mixed state lost |
| All rows selected | Header becomes checked | “checked” | Stuck on “partially checked” |
Space on header (custom) |
Toggles via aria-checked |
“checkbox, mixed” then “checked” on activation | Silent; role or state missing |
| Click while indeterminate | Resolves to checked (default) | “checked” | Cycles to an unexpected state |
Integration context
Permalink to "Integration context"This control is Step 2 of the parent bulk selection and batch actions implementation, where the selection set is built from per-row checkboxes and the header state is derived from it. Every state change here should be paired with the count announcement from announcing selection count changes in data grids — the header’s dash tells a sighted user the selection is partial, and the count tells a screen reader user how partial. When the grid is also sortable, re-run syncSelectAll after each sort so the header reflects the selection that survived the reorder, as discussed in sortable and filterable data grids.
Gotchas
Permalink to "Gotchas"1. The property resets on re-render
Permalink to "1. The property resets on re-render"A framework that reconciles attributes will not touch the indeterminate property, so after a re-render it silently falls back to false and the header shows an empty box while rows remain selected. Re-apply it in a useLayoutEffect (React), an updated hook (Vue), or after ngAfterViewChecked (Angular) — synchronously, before paint.
2. checked and indeterminate both true
Permalink to "2. checked and indeterminate both true" If you set checked = true and indeterminate = true together, the box paints a dash but the accessibility tree may report “checked” on some AT, hiding the partial state. The rule: whenever indeterminate is true, checked must be false.
3. Custom checkbox missing the mixed token
Permalink to "3. Custom checkbox missing the mixed token" Setting aria-checked="true" when only some rows are selected tells screen reader users everything is selected — a dangerous lie before a batch delete. Ensure the partial branch sets exactly aria-checked="mixed", and test that JAWS and NVDA announce it.
FAQ
Permalink to "FAQ"Why does my indeterminate checkbox render as unchecked?
Because indeterminate is a DOM property, not an HTML attribute. Writing indeterminate in markup, or binding it like a normal attribute in a framework template, has no effect — the browser ignores it. You must set element.indeterminate = true in JavaScript after the element is rendered, and re-apply it after every framework re-render, because the value does not survive a DOM diff that only tracks attributes.
What happens when a user clicks an indeterminate checkbox?
Clicking clears the indeterminate state and sets the checkbox to checked in most browsers, firing a change event whose checked value is true. The browser will not cycle back to indeterminate on its own — that state is only ever set programmatically. Decide whether a click on the half-selected header should select all or clear all, then set checked and indeterminate explicitly in your handler rather than relying on the default.
Should I use a native checkbox or aria-checked=mixed?
Use a native input type="checkbox" and its indeterminate property whenever you can, because you inherit Space toggling, the focus ring, and the mixed-state announcement for free. Only reach for a div with role="checkbox" and aria-checked="mixed" when a design constraint makes the native control unusable, and then you must re-implement keyboard handling and the three-way state yourself.
Related
Permalink to "Related"- Bulk selection & batch actions — the full selection model this header checkbox drives
- Announcing selection count changes in data grids — the running count that accompanies the tri-state
- Sortable & filterable data grids — re-syncing header state after a sort re-render