# Select-all checkboxes whose dash is never read out

Source: https://easeweb.dev/learn/select-all-checkboxes
Topics: Forms > Select-all checkboxes show when only some items are selected (WCAG 4.1.2)
The fix: Put the partial state in code, not only in the drawing. Either set the native checkbox's indeterminate property from the script and draw the dash from :indeterminate, or, where the select-all is a custom checkbox, set aria-checked="mixed" and draw the dash from it.
Test with: screen reader, keyboard, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://html.spec.whatwg.org/multipage/input.html#dom-input-indeterminate https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox#indeterminate_state_checkboxes https://developer.mozilla.org/en-US/docs/Web/CSS/:indeterminate https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/examples/checkbox-mixed/ https://www.w3.org/TR/html-aria/#docconformance

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

An account page lists invoices, each with a checkbox, under a Select all checkbox and above a Download selected button. Tick March and April, and the Select all box changes from empty to a dash. The dash is the list's summary: at a glance, it says that some invoices are ticked, but not all. On a long list it is often the only sign that a row further down, out of view, is still ticked.

With a screen reader, tick the same two invoices, then press Shift + Tab to go back up to Select all. It is announced as "Select all, checkbox, not checked". The box on screen shows a dash; the checkbox in code is simply not checked. Press Space and every invoice is ticked, as it would be with a mouse, so the box works. What fails is what it says: a screen reader user who comes to the list later, or reads it from the top, hears that Select all is not checked and has no way to tell, short of reading every row, that two invoices are already selected.

## Why it fails

Success criterion 4.1.2 Name, Role, Value asks that the state of a control people can set is available to assistive technology. A checkbox shown with a dash is in a third state, partly checked, and that state is only on screen.

An HTML checkbox has two checked values, checked and not checked. The third look comes from a separate flag, `indeterminate`, and it exists only as a property in script: there is no `indeterminate` attribute, so it can't be written into the markup or set by a template as one. When a script sets it to `true`, the browser draws its own dash, matches `:indeterminate` in CSS, and reports the checkbox as mixed, which screen readers read as "partially checked", "half checked" or "mixed". The script here never sets it. It adds a class, and the CSS draws a dash on the box from the class, so the drawing and the state the browser reports are two separate things, and only the drawing changed.

The same dash is often wired up in other ways that fail the same way:

- `aria-checked="mixed"` added to the native checkbox. ARIA in HTML forbids `aria-checked` on an `input type="checkbox"`, and Chrome ignores it and reports the input's own checked state, so the box still reads as not checked.
- A custom checkbox, a `button` or `div` with `role="checkbox"`, whose script sets `aria-checked` only to `"true"` or `"false"`, never to `"mixed"`.
- A script that sets `checked = true` as soon as any row is ticked, then draws a dash over the tick: the box shows a dash and is read as checked, as if every invoice were selected.

## Who is affected

Select all sits above lists that run past the screen: invoices, messages, files, orders, rows in a table. Its look is the one place that sums up the whole list, and when the summary is only drawn, people who don't see it get no summary at all.

## What automation and AI miss

The checkbox has a label, a role and a valid state, so axe and other scanners pass it: nothing in the markup is wrong, only missing, and a scanner doesn't compare what is drawn with what is reported. EaseWeb's audit ticks one row with Space and reports a select-all that is redrawn while it still reads as not checked, as `ew:select-all-state-missing`. Asked to fix it, AI coding tools can add `aria-checked="mixed"` to the native checkbox, or write `indeterminate` into the markup as an attribute. Neither changes what Chrome reports: the checkbox still reads as not checked. axe does catch the first, as `aria-conditional-attr`, since `aria-checked` on a native checkbox disagrees with its real state, but the fix it then asks for is to remove the attribute, which leaves the dash unreported again. Check the select-all's state in the browser's accessibility panel, not its class.

## Before: the dash drawn from a class

```html
<form action="/invoices/download" method="post">
  <label class="check">
    <input type="checkbox" id="select-all">
    Select all
  </label>
  <ul class="invoices">
    <li>
      <label class="check">
        <input type="checkbox" name="invoice" value="2026-03">
        March 2026
      </label>
    </li>
    …
  </ul>
  <button type="submit">Download selected</button>
</form>
```

```css
/* The box and its tick, drawn on the checkbox itself */
.check input { … }
.check input:checked { … }
/* Some rows ticked: a dash in place of the tick */
.check input.partial {
  border-color: #4f46e5;
  background: #4f46e5;
}
.check input.partial::before {
  visibility: visible;
  inline-size: 0.625rem;
  block-size: 0;
  border-width: 0 0 3px;
  rotate: none;
}
```

```js
const all = document.querySelector('#select-all');
const rows = [...document.querySelectorAll('input[name="invoice"]')];

// Select all ticks or unticks every row.
all.addEventListener('change', () => {
  rows.forEach((row) => { row.checked = all.checked; });
  all.classList.remove('partial');
});

// Ticking a row updates Select all: ticked when every row is, a dash when some are.
rows.forEach((row) => row.addEventListener('change', () => {
  const ticked = rows.filter((r) => r.checked).length;
  all.checked = ticked === rows.length;
  all.classList.toggle('partial', ticked > 0 && !all.checked);
}));
```

With two of the rows ticked, the script sets `checked` to `false` and adds the `partial` class, and the CSS draws the dash from the class. The browser reports what the checkbox holds, not what is drawn on it, so a screen reader announces "not checked".

## After: the indeterminate property

```html
<form action="/invoices/download" method="post">
  <label class="check">
    <input type="checkbox" id="select-all">
    Select all
  </label>
  <ul class="invoices">
    <li>
      <label class="check">
        <input type="checkbox" name="invoice" value="2026-03">
        March 2026
      </label>
    </li>
    …
  </ul>
  <button type="submit">Download selected</button>
</form>
```

```css
/* The box and its tick, drawn on the checkbox itself */
.check input { … }
.check input:checked { … }
/* Some rows ticked: a dash in place of the tick */
.check input:indeterminate {
  border-color: #4f46e5;
  background: #4f46e5;
}
.check input:indeterminate::before {
  visibility: visible;
  inline-size: 0.625rem;
  block-size: 0;
  border-width: 0 0 3px;
  rotate: none;
}
```

```js
const all = document.querySelector('#select-all');
const rows = [...document.querySelectorAll('input[name="invoice"]')];

// Select all ticks or unticks every row.
all.addEventListener('change', () => {
  rows.forEach((row) => { row.checked = all.checked; });
});

// Ticking a row updates Select all: ticked when every row is, mixed when some are.
rows.forEach((row) => row.addEventListener('change', () => {
  const ticked = rows.filter((r) => r.checked).length;
  all.checked = ticked === rows.length;
  all.indeterminate = ticked > 0 && !all.checked;
}));
```

The script sets the checkbox's own `indeterminate` property where it used to add the class, and the CSS draws the dash from `:indeterminate`, which matches exactly while the property is `true`. The state a screen reader reads and the dash people see now come from one value, so they can't disagree. The `remove('partial')` line goes too: when someone presses a mixed checkbox, the browser clears `indeterminate` itself and ticks the box, so Select all then ticks every row, as it did before.

## After: a custom checkbox set to mixed, when the select-all is not an input

```html
<form action="/invoices/download" method="post">
  <button type="button" class="check" id="select-all" role="checkbox" aria-checked="false">
    <span class="box"></span>
    Select all
  </button>
  <ul class="invoices">
    <li>
      <label class="check">
        <input type="checkbox" name="invoice" value="2026-03">
        March 2026
      </label>
    </li>
    …
  </ul>
  <button type="submit">Download selected</button>
</form>
```

```css
/* The box and its tick: drawn on the rows' checkboxes, and on the span in Select all */
.check input,
.check .box { … }
.check input:checked,
.check[aria-checked="true"] .box { … }
/* Some rows ticked: a dash in place of the tick */
.check[aria-checked="mixed"] .box {
  border-color: #4f46e5;
  background: #4f46e5;
}
.check[aria-checked="mixed"] .box::before {
  visibility: visible;
  inline-size: 0.625rem;
  block-size: 0;
  border-width: 0 0 3px;
  rotate: none;
}
```

```js
const all = document.querySelector('#select-all');
const rows = [...document.querySelectorAll('input[name="invoice"]')];

// Select all ticks every row, or unticks them all when every one is ticked.
all.addEventListener('click', () => {
  const tick = all.getAttribute('aria-checked') !== 'true';
  rows.forEach((row) => { row.checked = tick; });
  all.setAttribute('aria-checked', String(tick));
});

// Ticking a row updates Select all: true when every row is, mixed when some are.
rows.forEach((row) => row.addEventListener('change', () => {
  const ticked = rows.filter((r) => r.checked).length;
  all.setAttribute('aria-checked', ticked === rows.length ? 'true' : ticked ? 'mixed' : 'false');
}));
```

`aria-checked` takes three values, and `"mixed"` is the one for a checkbox that stands for a partly ticked group. A `button` with `role="checkbox"` keeps the button's own focus and its Space key, and its text, "Select all", is its name; the script sets all three states, and the CSS draws the tick and the dash from them, so they can't drift apart. Everything a native checkbox does by itself is now this script's job: it has to update `aria-checked` on every change, and pressing the button is what ticks or unticks the rows. The select-all is no longer a form field, which is fine here, since only the rows are sent.

## Which option to use

Both pass. Use the `indeterminate` property whenever the select-all is a native checkbox, which it can nearly always be: it is one line in the script you already have, the browser reports the mixed state and clears it when the box is pressed, and nothing else has to change. That is why it is the recommended fix. Use `aria-checked="mixed"` when the select-all is already a custom checkbox, such as one from a component library that draws every checkbox as a button, and set all three states from the same script that ticks the rows. Don't rebuild a native checkbox as a custom one to get the dash: the property gives it the dash and the mixed state as it is.

## Implementation decisions

Because `indeterminate` is only a property, nothing in the markup can set it, and nothing restores it. Set it when the page loads, from the rows that are already ticked, and again whenever the rows change without anyone ticking one: after a form reset, which restores each checkbox's checked state but leaves `indeterminate` alone, after filtering or paging the list, and after rows are added or removed. A template or framework that writes `indeterminate` into the markup does nothing: set the property in the component's script, for example from a ref after each render.

Pressing a mixed checkbox ticks it here: Select all goes from a dash to a tick and ticks every row. The checkbox pattern in the ARIA Authoring Practices also shows a select-all that ticks all, then unticks all, and on the third press returns to the partial selection someone had made. That takes more script, and either behaviour passes 4.1.2, as long as the state reported matches the box at every step.

Draw "some" as a mark of its own, a dash or a small square, not as a paler fill of the box: a fill alone tells some from none by colour only, which fails 1.4.1 Use of Color. Draw the dash with a border, as here, not with a filled shape. In a Windows contrast theme the browser removes background fills but redraws borders in the user's colours, so a dash drawn as a border stays, and the three states can still be told apart.

Nothing needs announcing when a row's tick changes Select all. Focus is on the row, and a screen reader reads Select all's state when someone moves to it, which is when they need it. A live region saying "2 of 4 selected" can be added as a convenience, but it doesn't replace the mixed state.

## Verify the fix

1. With a screen reader on, tick one row, move back to Select all and confirm it is announced as partially checked, half checked or mixed, whichever word your screen reader uses, while the dash shows.
2. Tick every row and confirm Select all reads as checked with a tick; untick them all and confirm it reads as not checked with an empty box.
3. With one row ticked, press Space on Select all: every row is ticked and Select all reads as checked. Press Space again: every row is unticked and it reads as not checked.
4. In the browser's accessibility panel, with some rows ticked, Select all's checked state is "mixed", and the dash still shows with a Windows contrast theme on or forced colors emulated.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the list component or its checkboxes change.

## Limits

This is a starting pattern, not a guarantee for every list. It covers the select-all's state; a checkbox that the keyboard can't reach at all is covered in [custom checkboxes that keyboards and screen readers skip](/learn/custom-checkboxes), and a list whose chosen options are never read out in [multi-selects with silent ticks and mouse-only chips](/learn/multi-select). A tree of checkboxes, where each group has its own select-all, needs the same mixed state at every level. Test the whole list with the screen readers your users rely on.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
