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 indeterminate as an attribute. It does nothing. Set the property in JavaScript.
  • Leave checked true while indeterminate. Some AT and browsers will announce “checked” and hide the mixed state. Force checked = false whenever indeterminate = 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" 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.


Permalink to "Related"

Back to Bulk Selection & Batch Actions