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-posinset and aria-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).

What each reader says on expand and on entering a child Matrix of NVDA, JAWS, VoiceOver and TalkBack showing the announcement on expanding a treegrid row and on moving to its first child, and whether level is read. What each reader says on expand and on entering a childReaderOn expandFirst childLevel readNVDA + Chrome"expanded"Row content, "1 of 14""level 2"JAWS + Chrome"open" or "expanded"Row content, positionOn level changeVoiceOver + Safari"expanded"Row content; position variesSometimes omittedTalkBack + Chrome"expanded"Cell onlyRarely read
Every reader announces the state change; they differ on level and on how much of the child row they read.

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 — — —
Classifying a deviation Flow for classifying a treegrid announcement deviation: record the phrase, compare with the expected phrase, check the accessibility tree, then classify as markup bug or reader behaviour. Classifying a deviationRecordspoken phrase logCompareagainst expectedCheck treelevel, expanded,setsize present?Classifymarkup bug orreader behaviourActfix, or document
Check the accessibility tree before blaming the screen reader — most deviations start there.

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.

Compensating with aria-label versus with content Comparison of adding level, position and state into each row's aria-label against putting the child count in the row's visible content and leaving state to ARIA attributes. Compensating with aria-label versus with content✗ Spell it out in aria-label"Finance, level 1, 1 of 3, expanded"Overrides the row's content nameDoubled in readers that already say itMust be updated on every state change✓ Put facts in content"Finance · 14 accounts" visible in the rowState and level from ARIA attributesEach reader adds what it supportsNothing to keep in sync by hand
Content-based information is read by every reader; aria-label strings duplicate and drift.

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.

Permalink to "Related"

← Back to Assistive Technology Behaviour Differences