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-checked on treeitem — true, false or mixed. The ARIA tree pattern supports checked state as an alternative to aria-selected for 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.
Checked, unchecked and mixed states cascading Hierarchy of a permissions tree: Reports is mixed because Sales reports is checked and Finance reports is unchecked; Sales reports has two checked children; Finance reports has two unchecked children. Checked, unchecked and mixed states cascadingReportsmixed — some descendants checkedSales reportschecked — all children checkedPipelinecheckedForecastcheckedFinance reportsuncheckedLedgeruncheckedPayrollunchecked
A parent's state is computed from its descendants.

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.

Nested native checkboxes versus aria-checked treeitems Comparison: nested checkbox inputs add a tab stop per item and double announcements; aria-checked treeitems keep one tab stop and the tree's arrow keys. Nested native checkboxes versus aria-checked treeitems✗ Nested input type=checkboxTab stop per checkbox"Tree item… checkbox, not checked" twiceMixed state needs indeterminate propertyArrow keys conflict with inputs✓ aria-checked on treeitemOne tab stop for the tree"Pipeline, checked, level 3"aria-checked="mixed"Arrows navigate; Space toggles

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 —
How readers announce checked tree items Matrix of NVDA, JAWS, VoiceOver and TalkBack against announcing checked, not checked and mixed on treeitems. How readers announce checked tree itemsCheckedNot checkedMixedNVDA + Chrome"checked""not checked""partially checked"JAWS + Chrome"checked""not checked""partially checked"VoiceOver + Safari"checked""unchecked""mixed"TalkBack + Chrome"checked""not checked""partially checked" inrecent versions
Mixed is announced as "partially checked" or "mixed"; both are understood.

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.

Permalink to "Related"

← Back to Tree Views & Hierarchical Navigation