How Screen Readers Announce Treegrid Expansion
Permalink to "How Screen Readers Announce Treegrid Expansion"A treegrid combines a grid’s rows and columns with a tree’s hierarchy: rows can expand to reveal child rows, each at a deeper level. When a user presses Right Arrow on a collapsed row, they need to hear that it expanded and, ideally, how many children appeared; when they move into a child, they need its level and position. Screen readers do not agree on how much of that they say. This page records the differences for the four major readers and sets out a testing approach so teams can tell a reader quirk from a markup bug.
It belongs to assistive technology behaviour differences. The component itself is built in building an accessible treegrid with expandable rows.
Spec reference
Permalink to "Spec reference"In role="treegrid", each row may carry:
aria-expanded="true|false"on rows that have children (omit on leaf rows);aria-level— 1 for top-level rows, increasing with depth;aria-posinsetandaria-setsize— position among siblings at the same level.
Expanding a row changes aria-expanded on the focused row — a state change on the focused element, which every screen reader is expected to announce. Children appearing in the DOM are not announced by themselves; they are discovered when the user moves into them. Whether the reader says how many children there are is reader-specific, since aria-setsize lives on the children, not the parent.
Criteria: SC 4.1.2 Name, Role, Value (expanded state), SC 1.3.1 Info and Relationships (level and position).
When to add a supplementary announcement — and when not to
Permalink to "When to add a supplementary announcement — and when not to"The state change itself is announced everywhere; do not duplicate it with a status message. “Expanded” twice is noise.
A short status message is worth adding when expansion loads children asynchronously (“14 items loaded under Finance”), because nothing else tells the user loading finished — see lazy-loading child rows in a treegrid. It can also be justified when your users rely heavily on a reader that omits level (TalkBack), and the hierarchy is deep — in that case include the level in the row’s visible or hidden text rather than announcing it.
The misapplication to name is compensating for reader differences with aria-label on every row that spells out “level 2, 3 of 14, expanded”. That overrides the row’s content-based name, freezes state into a string that must be kept in sync, and doubles the announcement in readers that already say it.
Annotated code example
Permalink to "Annotated code example"<!-- Reference treegrid used for recordings -->
<div role="treegrid" aria-label="Accounts" aria-readonly="true">
<div role="row" aria-level="1" aria-posinset="1" aria-setsize="3"
aria-expanded="false" tabindex="0">
<div role="gridcell">Finance</div>
<div role="gridcell">14 accounts</div> <!-- child count in content -->
</div>
<div role="row" aria-level="1" aria-posinset="2" aria-setsize="3"
aria-expanded="false" tabindex="-1">
<div role="gridcell">Operations</div>
<div role="gridcell">9 accounts</div>
</div>
</div>
// Recording harness: guidepup (NVDA / VoiceOver) — capture spoken phrases per action
import { nvda } from '@guidepup/guidepup';
test('treegrid expansion — NVDA', async ({ page }) => {
await nvda.start();
await page.goto('/fixtures/treegrid');
await nvda.perform(nvda.keyboardCommands.moveToNextFormField); // into the treegrid
await nvda.clearSpokenPhraseLog();
await page.keyboard.press('ArrowRight'); // expand
const onExpand = await nvda.spokenPhraseLog();
await page.keyboard.press('ArrowDown'); // first child
const onChild = await nvda.spokenPhraseLog();
expect(onExpand.join(' ')).toMatch(/expanded/i);
expect(onChild.join(' ')).toMatch(/level 2/i);
await nvda.stop();
});
Putting the child count in the parent row’s visible content (“14 accounts”) solves the “how many children?” question for every reader at once, because it is read as part of the row whether or not the reader uses aria-setsize.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Expected (spec) | NVDA | JAWS | VoiceOver | TalkBack |
|---|---|---|---|---|---|
Right Arrow on collapsed |
State → expanded | “expanded” | “open” | “expanded” | “expanded” |
Down Arrow into child |
Child row, level 2, 1 of 14 | All three | All, level on change | Row, level sometimes | Cell text |
Left Arrow on child |
Focus to parent | Parent row read | Parent row read | Parent row read | Parent cell |
Left Arrow on expanded parent |
State → collapsed | “collapsed” | “closed” | “collapsed” | “collapsed” |
* (optional) |
Expand all siblings | Varies by implementation | — | — | — |
Integration context
Permalink to "Integration context"The general approach — recording phrases and comparing them — is the smoke-test technique from writing a screen reader smoke test with Playwright. For status messages added on lazy loads, remember that VoiceOver handles politeness differently from NVDA, as described in VoiceOver versus NVDA aria-live politeness handling.
Collapsible row groups in static tables use a button’s aria-expanded instead, and are announced consistently across readers because the state is on a button — a reason to prefer that pattern when the table does not otherwise need to be a treegrid; see collapsing row groups with aria-expanded.
Gotchas
Permalink to "Gotchas"aria-expanded on leaf rows. Setting aria-expanded="false" on rows with no children makes readers announce “collapsed” for leaves, suggesting they can expand. Omit it on leaves.
Level starting at 0. aria-level is 1-based; 0 is invalid and some readers ignore it.
Rows re-rendered on expand. Re-creating the parent row on expansion moves focus and loses the state announcement. Update attributes in place.
Design system notes
Permalink to "Design system notes"Keep a reference treegrid fixture in the component library with recorded phrase logs for NVDA and VoiceOver, and a manual record for JAWS and TalkBack. Re-record on reader upgrades; the diff tells you whether a change is yours or theirs.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Do all screen readers announce when a treegrid row expands?
Yes — NVDA, JAWS, VoiceOver and TalkBack all announce the change of aria-expanded on the focused row, though the word varies (“expanded” or “open”).
Why doesn't the screen reader say how many child rows appeared?
Because the count lives in aria-setsize on the children, which are only read when the user moves into them. Show the child count in the parent row’s visible content so every reader includes it.
TalkBack does not read the level of treegrid rows. What should I do?
Treat it as reader behaviour, not a markup bug, provided aria-level is set correctly. If level is essential for your users, make it part of the row’s visible or visually hidden content rather than overriding the name with aria-label.
Should I announce "expanded" in a live region as well?
No. The state change is already announced because focus is on the row. Use a live region only for asynchronous results such as children finishing loading.
Related
Permalink to "Related"- Building an accessible treegrid — the component under test
- Lazy-loading child rows — expansion with loading
- VoiceOver versus NVDA live politeness — the status message caveat