Assistive Technology Behaviour Differences

Permalink to "Assistive Technology Behaviour Differences"

The same ARIA markup does not produce the same speech. A role="grid" with aria-sort="ascending" on a column header might be read as “sorted ascending column” in one screen reader, as just the column name in another, and silently in a third. These differences are not random: each screen reader consumes a specific platform accessibility API, maintains its own model of the page, and applies its own heuristics for turning roles and states into words. This section explains why the four major assistive technologies diverge, gives a repeatable method for triaging cross-AT bugs, and links to three deep-dives on the specific data-UI patterns where divergence bites hardest.

This is a reference for engineers who have already built a pattern correctly — valid roles, names, and states in the accessibility tree — and are now debugging why one reader behaves unlike the others. If you are still constructing the pattern, start with the relevant implementation guide and return here when the announcements disagree.


WCAG Criteria in Scope

Permalink to "WCAG Criteria in Scope"
Criterion Level Relevance to AT behaviour differences
1.3.1 Info and Relationships A Roles and relationships must survive the mapping into every platform accessibility API, not only the one you test on
2.1.1 Keyboard A Grid and composite-widget key handling differs across desktop readers and touch AT; every action must remain operable
4.1.2 Name, Role, Value A Divergent name/role/value announcements almost always trace back to how each AT reads this triple from the tree
4.1.3 Status Messages AA Live region politeness and queueing are the most inconsistently implemented features across screen readers

Cite these identifiers when filing an AT-specific bug: they anchor the discussion in a normative requirement rather than a subjective preference about wording.


Prerequisites

Permalink to "Prerequisites"

To use this section effectively you should already understand:

You should also be comfortable opening a browser’s accessibility tree inspector, because every triage step below starts there.


The Accessibility Tree: One Source, Four Readers

Permalink to "The Accessibility Tree: One Source, Four Readers"

An assistive technology never reads your HTML. It reads a computed accessibility tree that the browser builds from the DOM plus your ARIA, and it reaches that tree through a platform accessibility API — an operating-system interface that exposes each node’s role, name, state, and relationships. Every divergence you will ever debug happens at one of three points: how the browser computes the tree, how the platform API represents a given property, and how the AT chooses to speak it.

From ARIA to speech across four assistive technologies A single DOM-plus-ARIA source computes into one accessibility tree. The tree is exposed through four platform accessibility APIs: MSAA and IA2 to NVDA and JAWS on Windows, UIA also to NVDA and JAWS, AX to VoiceOver on macOS and iOS, and the Android accessibility framework to TalkBack. Each screen reader interprets its API and produces different speech from the same source. DOM + ARIA source role, name, state, relationships Accessibility tree computed by the browser MSAA / IA2 Windows legacy + rich UIA Windows modern AX API macOS / iOS Android a11y framework NVDA / JAWS virtual buffer reading mode JAWS may prefer UIA per browser VoiceOver live DOM, no virtual buffer TalkBack explore by touch + swipe order Same source, four APIs, four interpretation layers — four different spoken results

The single most useful mental model: your markup is upstream of everything, but four independent interpretation layers sit downstream of a shared tree. Fixing markup fixes the tree for all of them at once; the residual differences after the tree is correct are the AT’s own behaviour, and those are what this section catalogues.


Spec Reference: What Each Layer Is Responsible For

Permalink to "Spec Reference: What Each Layer Is Responsible For"

Platform accessibility APIs (attribute-by-attribute)

Permalink to "Platform accessibility APIs (attribute-by-attribute)"
  • MSAA (Microsoft Active Accessibility) — the oldest Windows API. It exposes a small fixed set of roles and states and cannot represent most ARIA properties on its own. aria-sort, aria-rowcount, and aria-posinset have no native MSAA equivalent.
  • IA2 (IAccessible2) — a supplement that rides alongside MSAA to carry the rich ARIA properties MSAA lacks, exposed as “object attributes” (string key/value pairs). NVDA and JAWS read aria-sort, aria-level, aria-rowindex, and similar from IA2 object attributes. If a value is missing in one reader on Windows, check whether it is being carried by IA2 at all.
  • UIA (UI Automation) — the modern Windows API. It models controls as patterns (for example, a Grid pattern with row and column counts). Some ARIA maps cleanly to a UIA pattern and some does not, so a reader consuming UIA can announce different structural detail than one consuming IA2 from the same page.
  • AX API (macOS and iOS accessibility) — the Apple interface VoiceOver consumes. It has its own role vocabulary; the browser maps ARIA to AX roles and attributes, and unmapped properties are dropped silently rather than approximated.
  • Android accessibility framework — the interface TalkBack consumes through Chrome. It is built around AccessibilityNodeInfo objects optimised for touch exploration, which is why grid and row/column semantics that desktop readers expose are frequently flattened.

AT reading models

Permalink to "AT reading models"
  • Virtual buffer (NVDA, JAWS) — on Windows, the reader snapshots the accessibility tree into an off-screen buffer and lets the user navigate that cached copy with the virtual cursor. This is fast and enables reading-mode commands, but the buffer can go stale relative to the live DOM until the reader refreshes it.
  • Live DOM (VoiceOver) — VoiceOver navigates the live accessibility tree directly rather than a cached buffer, so it reflects the current state more immediately but handles rapid mutation bursts differently.
  • Explore by touch (TalkBack) — TalkBack has no virtual cursor; the user drags a finger to hit-test nodes or swipes to move through a linearised order. Structural navigation commands available on the desktop simply do not exist.

The governing WCAG requirement across all of this is 4.1.2 Name, Role, Value (Level A): name, role, and state must be programmatically determinable. WCAG does not mandate identical speech — it mandates that the information be present in the tree. That distinction is the crux of every triage decision below.


A General Triage Method for Cross-AT Bugs

Permalink to "A General Triage Method for Cross-AT Bugs"

When one AT disagrees with another, work these steps in order. Each isolates one variable so you can attribute the difference to a specific layer.

Step 1 — Fix a baseline pair (WCAG 4.1.2)

Permalink to "Step 1 — Fix a baseline pair (WCAG 4.1.2)"

Choose one AT plus browser pair as your reference and reproduce the intended behaviour there before touching anything else. Changing the reader and the browser at the same time makes the result uninterpretable.

// Record the baseline announcement verbatim before comparing readers.
// SC 4.1.2 Name, Role, Value — capture what each layer actually speaks.
const baseline = {
  pair: 'NVDA 2025 + Firefox',
  interaction: 'Focus column header "Status", press Enter to sort',
  spoken: 'Status, sorted ascending, column header, button'
};
// Store this string; every other reader is diffed against it, not against your expectation.

Step 2 — Inspect the computed accessibility tree (WCAG 1.3.1)

Permalink to "Step 2 — Inspect the computed accessibility tree (WCAG 1.3.1)"

Open the browser’s accessibility tree inspector and read the computed role, name, and state for the failing node. If the tree is wrong, stop: the bug is in your markup and every reader will be affected. If the tree is correct, the divergence is downstream.

// Programmatic sanity check that the source attributes are present
// before blaming the AT. SC 1.3.1 Info and Relationships.
const th = document.querySelector('th[aria-sort]');
console.assert(th.getAttribute('role') === 'columnheader' || th.tagName === 'TH',
  'Host role must be columnheader for aria-sort to map');   // valid host
console.assert(['ascending', 'descending', 'none', 'other'].includes(
  th.getAttribute('aria-sort')), 'aria-sort must be a valid token');

Step 3 — Identify the platform API in play (WCAG 4.1.2)

Permalink to "Step 3 — Identify the platform API in play (WCAG 4.1.2)"

Map the failing reader to its API. If NVDA speaks aria-sort but JAWS on the same machine does not, you are looking at an IA2-versus-UIA object-attribute difference, not a code bug. If VoiceOver drops a property NVDA speaks, the ARIA likely has no AX mapping.

Step 4 — Distinguish virtual buffer from live DOM

Permalink to "Step 4 — Distinguish virtual buffer from live DOM"

Ask whether the reader is showing you a cached buffer. If a value updates in VoiceOver immediately but appears stale in JAWS, force a buffer refresh (in NVDA, NVDA+F5) and re-test. A difference that vanishes after a refresh is a buffer-timing issue, addressed by re-announcing through a live region rather than by mutating the attribute alone.

Step 5 — Record the deviation as a baseline

Permalink to "Step 5 — Record the deviation as a baseline"

Write down the exact spoken string per AT. A catalogued difference is a regression test; an anecdote is not. This record is what turns “VoiceOver sounds weird” into an actionable, re-checkable line item.


Keyboard Interaction Contract

Permalink to "Keyboard Interaction Contract"

The keys a user presses to reach the same information differ per AT, and a pattern that is operable in one model can be unreachable in another. This contract lists the canonical command per reader for the running data-grid example.

Interaction NVDA / JAWS (Windows) VoiceOver (macOS) TalkBack (Android) Failure indicator
Move to next column header Arrow keys in browse mode, or Ctrl+Alt+→ in a table VO+→ in the table Swipe right, or explore by touch Header skipped; announced as generic cell
Read current cell’s row and column NVDA+F7/table commands VO reads row/column on move Not exposed; position often flattened “Row null” or no position spoken
Hear sort state on a header Announced on focus from IA2 From AX on VO+→ Frequently omitted Silence on the sort state
Trigger a live-region update Fires on DOM mutation into buffer Fires against live DOM Fires after current utterance Update dropped or spoken twice
Enter grid application mode Enter/focus mode toggle Interact (VO+Shift+↓) No equivalent User trapped or unable to enter

Screen Reader Compatibility Matrix

Permalink to "Screen Reader Compatibility Matrix"

This is the large cross-AT reference for the four data-UI properties that diverge most. Each cell describes the observed behaviour when the accessibility tree is already correct — so any difference here is the reader’s own interpretation, not a markup defect.

Property / event NVDA (Chrome/Firefox) JAWS (Chrome/Edge) VoiceOver (Safari) TalkBack (Chrome, Android)
aria-sort on <th> Announces direction on focus, read from IA2 object attributes Prefers the button aria-label; may not speak raw aria-sort Reads from AX; often needs direction encoded in the accessible name Frequently omits sort state entirely
aria-live="polite" Queues after current utterance; may merge rapid updates Queues; can drop earlier update if an assertive region fires Live DOM; stricter aria-atomic boundary, may skip intermediate values Fires after the current utterance completes
aria-live="assertive" Interrupts immediately Interrupts; simultaneous assertive regions can garble Interrupts regardless of Quick Nav mode Interrupts, but touch focus can suppress it
role="grid" navigation Full arrow-key cell navigation in focus mode Full grid navigation; strong table commands Interact model; VO+arrows per cell Weak grid support; often linearised to a list
aria-rowcount / aria-colcount Read from IA2; announces “row X of N” Announces totals with table commands Announced via AX table semantics Inconsistent; totals frequently not spoken
aria-posinset / aria-setsize Announces “X of N” on move Announces position reliably Announces set position May announce raw index without total
Live region added after load Not observed (buffer registered at parse) Not observed Not observed Not observed

The pattern across the matrix: Windows readers expose the most structural ARIA because IA2 carries object attributes verbatim; VoiceOver requires more information folded into the accessible name because AX drops unmapped properties; TalkBack exposes the least grid structure because the Android framework is optimised for touch rather than tabular navigation.


Edge Cases & Failure Modes

Permalink to "Edge Cases & Failure Modes"

1. The tree is correct but only one reader is silent

Permalink to "1. The tree is correct but only one reader is silent"

This is the defining cross-AT bug. The accessibility tree inspector shows the right role, name, and state, yet one reader says nothing. Cause: the property has no mapping in that reader’s platform API (common for aria-sort under AX, or grid structure under the Android framework). Fix: fold the missing information into the accessible name, which every API carries, rather than relying on the structural property alone.

2. A value updates in one reader and lags in another

Permalink to "2. A value updates in one reader and lags in another"

VoiceOver reflects an attribute change against the live DOM immediately; NVDA or JAWS may show the old value until the virtual buffer refreshes. Fix: never rely on a silent attribute mutation to inform the user — pair every state change with a live region announcement that pushes the new value regardless of buffer state.

3. Rapid mutations announced differently

Permalink to "3. Rapid mutations announced differently"

A burst of DOM mutations under a polite live region is coalesced by NVDA (only the final value spoken), throttled internally by VoiceOver (some intermediate values skipped), and queued sequentially by JAWS (potentially several seconds of backlog). Fix: throttle at the source so the DOM emits one composed string per interval rather than one per event.

4. Grid semantics collapse on touch

Permalink to "4. Grid semantics collapse on touch"

A role="grid" that navigates cell-by-cell on the desktop is often flattened to a linear list by TalkBack, so row and column relationships vanish. Fix: ensure each cell’s accessible name is self-describing (for example, “Status: Active, row 4”) so meaning survives even when structure does not.

5. Application mode versus reading mode

Permalink to "5. Application mode versus reading mode"

Windows readers switch between a reading mode (virtual cursor over the buffer) and a focus/application mode (keystrokes pass to the widget). A composite widget that assumes application mode can be silent in reading mode, and vice versa. Fix: follow the expected keyboard interaction pattern for the role so the reader switches modes automatically.


Deep-Dive: NVDA vs JAWS aria-sort Announcements

Permalink to "Deep-Dive: NVDA vs JAWS aria-sort Announcements"

NVDA and JAWS run on the same operating system and can consume the same page, yet they announce aria-sort differently: NVDA reads the direction from IA2 object attributes on header focus, while JAWS leans on the button’s accessible name and may not speak the raw token at all. The timing relative to the DOM reorder also differs. The dedicated guide, NVDA vs JAWS: aria-sort announcement differences, gives the exact spoken strings, the timing rules, and the belt-and-braces workaround.

<!-- Encode direction in BOTH aria-sort and the accessible name so NVDA
     (reads aria-sort) and JAWS (reads the name) both announce it. -->
<!-- aria-sort → SC 4.1.2 Name, Role, Value (state) -->
<th scope="col" aria-sort="ascending">
  <button type="button" aria-label="Status, sorted ascending">
    Status <span aria-hidden="true"></span>
  </button>
</th>

Deep-Dive: VoiceOver vs NVDA Live Region Politeness

Permalink to "Deep-Dive: VoiceOver vs NVDA Live Region Politeness"

VoiceOver and NVDA both support aria-live, but they queue, interrupt, and coalesce updates differently. NVDA buffers politely and merges rapid updates; VoiceOver reads against the live DOM and enforces a stricter atomic boundary, dropping intermediate values a dashboard might depend on. The full treatment, including how each handles aria-atomic and burst updates, is in VoiceOver vs NVDA: aria-live politeness handling.

<!-- aria-atomic forces the whole region to be re-read; VoiceOver honours the
     boundary strictly, NVDA more loosely. -->
<!-- aria-live → SC 4.1.3 Status Messages -->
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only"
     id="dashboard-status"></div>

Deep-Dive: How TalkBack Differs for Data Grid Navigation

Permalink to "Deep-Dive: How TalkBack Differs for Data Grid Navigation"

TalkBack has no virtual cursor and no cell-by-cell grid navigation model. Users explore by touch or swipe through a linearised order, so role="grid", aria-rowcount, and aria-colcount support are patchy and row/column position is frequently unspoken. How TalkBack differs for data grid navigation explains the gaps and the self-describing-cell workaround that keeps meaning intact on touch.

<!-- Self-describing cell: meaning survives even when TalkBack flattens the grid. -->
<!-- aria-rowindex → SC 1.3.1 Info and Relationships -->
<div role="gridcell" aria-rowindex="4">
  <span class="sr-only">Status:</span> Active
</div>

Testing Checklist

Permalink to "Testing Checklist"

Automated

Permalink to "Automated"

Per-API manual coverage

Permalink to "Per-API manual coverage"

Regression capture

Permalink to "Regression capture"

FAQ

Permalink to "FAQ"
Is it a bug in my code if only one screen reader announces something wrong?

Not necessarily. If the accessibility tree exposes the correct role, name, and state, and one screen reader still speaks the wrong thing, the divergence is usually in that AT’s interpretation layer or its platform accessibility API mapping, not in your markup. Confirm the computed tree first, then decide whether a workaround is warranted or whether the behaviour is an acceptable variation.

Which single AT and browser pair should I test first?

Pick the pair with the largest share of your real users, then add one pair from each other platform family so you cover the four accessibility APIs. A common baseline set is NVDA with Chrome or Firefox on Windows, JAWS with Chrome on Windows, VoiceOver with Safari on macOS, and TalkBack with Chrome on Android. Testing one pair per API surfaces most mapping differences.


Permalink to "Related"

Back to Testing & Auditing Accessible Data Interfaces