Multi-Select Trees with Checkboxes
Permalink to "Multi-Select Trees with Checkboxes"Permission editors, category filters, folder sync settings and report builders often use checkbox trees: tick “Electronics” and every category under it is selected; untick “Phones” and Electronics becomes partly selected. The visual convention is clear — a box with a tick, an empty box, a box with a dash. The accessible version needs the same three states exposed, a key to toggle, and feedback about how many items a single press just changed.
This guide belongs to tree views & hierarchical navigation.
Spec reference
Permalink to "Spec reference"aria-checkedontreeitem—true,falseormixed. The ARIA tree pattern supports checked state as an alternative toaria-selectedfor multi-select.aria-multiselectable="true"on the tree when multiple items can be chosen.- Mixed state — a parent whose descendants are partly checked is
mixed; activating a mixed parent typically checks all descendants. - SC 4.1.2 Name, Role, Value (A) — state for every item, including mixed.
- SC 4.1.3 Status Messages (AA) — cascades that change many items are worth announcing.
- SC 1.4.1 / 1.4.11 (A/AA) — the three visual states must be distinguishable without colour and at 3:1.
When to use — and when not to
Permalink to "When to use — and when not to"Use a checkbox tree when users choose a subset of a hierarchy and the hierarchy helps them choose — permission scopes, categories to include, folders to sync.
Use a plain multi-select listbox or a list of checkboxes when the hierarchy is shallow (one level) or does not help. Do not nest native input type="checkbox" inside treeitems: the tree’s keyboard model and the inputs’ Tab stops fight, and screen readers announce both.
The misapplication: cascading without saying so. A user ticks “Reports” and silently checks 140 report permissions — a significant change they may not intend.
Annotated code example
Permalink to "Annotated code example"<ul role="tree" aria-label="Report permissions" aria-multiselectable="true">
<li role="treeitem" aria-expanded="true" aria-checked="mixed" tabindex="0">
<span class="box" aria-hidden="true"></span>Reports <!-- visual box hidden -->
<ul role="group">
<li role="treeitem" aria-expanded="true" aria-checked="true" tabindex="-1">Sales reports
<ul role="group">
<li role="treeitem" aria-checked="true" tabindex="-1">Pipeline</li>
<li role="treeitem" aria-checked="true" tabindex="-1">Forecast</li>
</ul></li>
</ul>
</li>
</ul>
<p id="perm-status" role="status" class="visually-hidden"></p>
function toggle(item) {
const next = item.getAttribute('aria-checked') !== 'true'; // mixed → true
const affected = [item, ...item.querySelectorAll('[role="treeitem"]')];
affected.forEach(n => n.setAttribute('aria-checked', String(next))); // cascade down
for (let p = parentItem(item); p; p = parentItem(p)) { // recompute ancestors
const kids = childItems(p).map(k => k.getAttribute('aria-checked'));
p.setAttribute('aria-checked', kids.every(k => k === 'true') ? 'true' : kids.every(k => k === 'false') ? 'false' : 'mixed');
}
const leaves = affected.filter(n => !n.hasAttribute('aria-expanded')).length;
if (leaves > 1) status.textContent = `${next ? 'Checked' : 'Unchecked'} ${leaves} reports. ${totalChecked()} selected in total.`;
}
tree.addEventListener('keydown', (e) => { if (e.key === ' ') { e.preventDefault(); toggle(e.target.closest('[role="treeitem"]')); } });
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Action | Expected announcement | AT deviations |
|---|---|---|---|
| Arrows | Navigate | “Reports, partially checked, expanded, level 1” | VoiceOver says “mixed” |
| Space on a leaf | Toggle | “checked” / “not checked” | — |
| Space on a parent | Toggle all descendants | “checked” then “Checked 12 reports. 14 selected in total.” | — |
| Space on a mixed parent | Check all | “checked” plus count | — |
| Right / Left | Expand / collapse | As in any tree | — |
Integration context
Permalink to "Integration context"The tree mechanics come from the tree role for category and file browsers. The mixed state mirrors select-all in tables — see implementing select all with indeterminate checkbox state. Counts follow announcing selection count changes in data grids.
Gotchas
Permalink to "Gotchas"Lazy children. If a collapsed parent’s children have not loaded, checking the parent must still mean “all of them”. Store the intent (“all under Reports”) rather than enumerating loaded nodes.
Disabled descendants. Some items may be locked. Skip them in the cascade, keep the parent mixed, and explain: “Checked 11 reports; 1 locked report unchanged.”
Selection and focus. Do not toggle on arrow keys. Only Space (and click) changes checked state.
Framework notes
Permalink to "Framework notes"React Aria Tree supports multiple selection with selectionMode="multiple", exposed via aria-selected; for tri-state cascading, you manage the logic and can render checkbox visuals. MUI X RichTreeView has checkboxSelection with multiSelect; check whether parents show mixed state in your version. Angular CDK tree leaves state to you. Whatever the library, verify the mixed announcement.
<RichTreeView items={items} multiSelect checkboxSelection selectionPropagation={{ descendants: true, parents: true }} />
Worked example
Permalink to "Worked example"A role editor had 180 permissions in a tree, each a nested native checkbox, plus a “select all” per branch. Keyboard users faced 180 tab stops; screen reader users heard each permission twice. Moving to aria-checked treeitems gave one tab stop, Space to toggle, mixed state on branches, and a status message for cascades (“Checked 14 permissions under Invoices. 37 selected in total.”). The editor’s least-liked moment — accidentally granting a whole branch — became audible.
Common mistakes
Permalink to "Common mistakes"- Mixed state shown only visually. A dash icon with
aria-checked="false"tells screen reader users the branch is empty. - Cascading on focus. Some implementations check a parent when its last child is checked and focus moves; state should change only on explicit toggles.
- Different meanings for click and Space. Clicking the label opens the node while Space toggles it; keep the checkbox action identical for pointer and keyboard, and put “open” on Enter.
- No total. After several cascades users lose track of how much is selected; show and announce the total.
Writing cascade messages
Permalink to "Writing cascade messages"Cascade messages should say what changed, under what, and the new total: “Checked 18 permissions under Invoices. 52 selected in total.” When the cascade skipped locked items, add them: “2 locked permissions unchanged.” When a parent becomes mixed because a child was unchecked, there is no need to announce the parent’s new state — users hear the child’s state, and the parent’s mixed state is read when they reach it.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Offer a CheckboxTree with cascade logic, locked items and count announcements built in, sharing visuals with the table select-all checkbox so the three states look the same everywhere.
FAQ
Permalink to "FAQ"aria-checked or aria-selected for multi-select trees?
Either is valid. aria-checked fits when the UI shows checkboxes and has a mixed state; aria-selected fits when selection is highlighted rows.
What should a mixed parent do on Space?
Check all descendants is the common convention. Announce the result so users know what happened.
Should cascades be optional?
Some editors offer “select only this item” for parents. If so, provide it as a separate command, not a hidden modifier.
How do I show the total selected?
In a visible count near the tree and in status messages after cascades.
Can I use native checkboxes outside the tree role?
Yes — a nested list of labelled checkboxes in fieldsets is fine for small hierarchies, without tree semantics. Do not mix both.
Do checked items need a visible focus indicator distinct from the tick?
Yes. Focus and checked are different states; show both clearly.
Related
Permalink to "Related"- The tree role — tree basics
- Select all with indeterminate state — the table counterpart
- Announcing selection counts — count messages