Screen Reader Testing Matrix: NVDA, JAWS, and VoiceOver — What Each Tests Differently
NVDA and JAWS both run on Windows, both read HTML, and both announce the same page — except they don't. ARIA live regions behave differently. Modal focus management works in one and silently fails in another. VoiceOver on iOS ignores ARIA patterns that work on macOS. This guide maps out the differences that matter for testing and shows you how to build a test matrix that gives real coverage without requiring a team of screen reader specialists.
The Screen Reader Landscape
| Screen Reader | Platform | Browser pairing | Market share (WebAIM 2024) |
|---|---|---|---|
| JAWS | Windows | Chrome, Firefox, IE | 40% |
| NVDA | Windows | Firefox, Chrome | 37% |
| VoiceOver | macOS, iOS | Safari | 9% (desktop) + significant mobile |
| TalkBack | Android | Chrome | 6% |
| Narrator | Windows | Edge | 3% |
The WebAIM survey is self-selected but the trends are consistent: JAWS + NVDA cover ~77% of Windows screen reader users. VoiceOver is the dominant mobile reader on iOS. Test both.
How Screen Readers Parse ARIA
Screen readers build an accessibility tree from the DOM. Browsers expose this tree via platform accessibility APIs:
- Windows: UI Automation (UIA) and legacy MSAA/IAccessible2
- macOS/iOS: NSAccessibility
- Linux: AT-SPI2
JAWS uses both UIA and IAccessible2 depending on the browser. NVDA primarily uses IAccessible2 in Firefox and UIA in Chrome. This creates behavioral differences even on the same page.
ARIA Behavior Matrix
The differences that cause the most test failures:
role="dialog" and focus management
| Screen reader | Behavior on dialog open |
|---|---|
| NVDA + Firefox | Announces role + accessible name, enters dialog mode |
| NVDA + Chrome | Announces role + accessible name, enters dialog mode |
| JAWS | Announces "dialog", reads aria-label / aria-labelledby, enters application mode |
| VoiceOver macOS | Announces dialog name, traps focus, announces "web dialog" |
| VoiceOver iOS | Navigate via swipe; focuses first element; may not announce dialog role |
| TalkBack | Announces role; may not trap focus in older versions |
What to test:
- When dialog opens, does focus move inside the dialog?
- Is the dialog name announced?
- Does Tab stay within the dialog?
- When dialog closes, does focus return to the trigger?
Test that aria-modal="true" is set — JAWS uses this to virtualize only dialog content.
<!-- Required pattern for all screen readers -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1" <!-- allows focus via JS -->
>
<h2 id="dialog-title">Confirm deletion</h2>
<!-- content -->
<button>Cancel</button>
<button>Delete</button>
</div>// Focus management on open
function openDialog(dialogEl) {
dialogEl.removeAttribute('hidden');
// Delay ensures screen reader picks up the modal before focus lands
requestAnimationFrame(() => {
dialogEl.focus();
});
}
// Restore focus on close
function closeDialog(dialogEl, triggerEl) {
dialogEl.setAttribute('hidden', '');
triggerEl.focus();
}aria-live regions
Live regions are where NVDA and JAWS diverge most significantly.
| Behavior | NVDA | JAWS | VoiceOver |
|---|---|---|---|
aria-live="polite" announces |
After current speech | After current sentence | After current speech |
aria-live="assertive" announces |
Immediately, interrupts | Immediately, interrupts | Immediately, interrupts |
aria-atomic="true" reads |
Entire region | Entire region | Entire region |
| Adding node vs. changing text | Both work | Both work | Text change preferred |
Role status equivalent |
aria-live="polite" |
aria-live="polite" |
aria-live="polite" |
Role alert equivalent |
aria-live="assertive" |
aria-live="assertive" |
May announce twice |
Critical NVDA bug: NVDA sometimes misses announcements when the live region is created and populated in the same tick. Always pre-render the region with empty content:
<!-- Pre-render empty region — NEVER create it dynamically -->
<div
role="status"
aria-live="polite"
aria-atomic="true"
id="status-message"
><!-- empty, populated by JS --></div>// Correct: update content of existing region
document.getElementById('status-message').textContent = 'Form submitted successfully';
// Wrong: creates region and adds content simultaneously
const region = document.createElement('div');
region.setAttribute('aria-live', 'polite');
region.textContent = 'Form submitted';
document.body.appendChild(region); // Often missed by NVDAVoiceOver quirk: VoiceOver on macOS occasionally announces a live region twice when aria-atomic="true" is combined with role="alert". Use role="status" with aria-live="assertive" if needed.
role="combobox" (autocomplete)
The combobox pattern is one of the most inconsistently supported ARIA patterns.
| Feature | NVDA | JAWS | VoiceOver macOS | VoiceOver iOS |
|---|---|---|---|---|
| ARIA 1.1 combobox | Good | Good | Good | Partial |
| ARIA 1.2 combobox | Good | Good | Partial | Poor |
| Announcing selected item | Yes | Yes | Yes | Sometimes |
| Option count announcement | NVDA ≥2022 | Yes | Yes | No |
aria-activedescendant updates |
Yes | Yes | Yes | Unreliable |
Use the ARIA 1.2 pattern but test on VoiceOver iOS separately. Always announce state explicitly:
<input
role="combobox"
aria-expanded="true"
aria-autocomplete="list"
aria-controls="suggestions-list"
aria-activedescendant="suggestion-0"
aria-label="Search"
/>
<ul role="listbox" id="suggestions-list">
<li role="option" id="suggestion-0" aria-selected="true">Result one</li>
<li role="option" id="suggestion-1" aria-selected="false">Result two</li>
</ul>Tab panels
| Feature | NVDA | JAWS | VoiceOver |
|---|---|---|---|
| Arrow key navigation within tablist | Yes | Yes | No |
| VoiceOver uses | Tab key | Tab key | Tab key |
Panel association via aria-controls |
Supported | Supported | Ignored |
| Active tab announcement | "selected" | "selected" | "selected" |
VoiceOver does not implement the WAI-ARIA tablist keyboard pattern (arrow keys). VoiceOver users navigate tabs with Tab, not arrow keys. Implement both:
tablist.addEventListener('keydown', (e) => {
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
const currentIdx = tabs.indexOf(document.activeElement);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
tabs[(currentIdx + 1) % tabs.length].focus();
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
tabs[(currentIdx - 1 + tabs.length) % tabs.length].focus();
}
});VoiceOver users can Tab to each tab because each has role="tab" and tabindex="0" (or managed tabindex), which puts them in the natural tab order.
Keyboard Shortcut Conflicts
Screen readers have global keyboard shortcuts that conflict with application shortcuts:
| Key | NVDA | JAWS | VoiceOver |
|---|---|---|---|
H |
Jump to next heading | Jump to next heading | — |
B |
Jump to next button | Jump to next button | — |
F |
Jump to next form field | Jump to next form field | — |
Ctrl+Home |
Jump to page top | Jump to page top | — |
Insert+F7 |
Links dialog | — | — |
These only apply in browse mode (NVDA/JAWS). In forms mode / application mode, native key events pass through to the application.
role="application" forces screen readers into forms/application mode permanently — avoid unless you're building a full application (like a text editor). For most components, the automatic mode switching triggered by form elements is sufficient.
Testing Procedures by Screen Reader
NVDA on Windows + Firefox
Setup:
- NVDA 2023.3+
- Firefox current
- Speech viewer: NVDA menu → Tools → Speech Viewer (shows announcements as text)
Test procedure for a modal dialog:
- Open Speech Viewer
- Navigate to the trigger button (Tab to it or browse mode
B) - Press Enter/Space
- Speech Viewer should show: role ("dialog"), name (aria-labelledby content)
- Press Tab through dialog elements — verify all interactive elements announced
- Press Escape — focus should return to trigger, dialog should close announcement
NVDA browse mode commands:
H → next heading
Shift+H → previous heading
B → next button
F → next form field
NVDA+Space → toggle forms/browse mode
NVDA+F7 → list all headings/linksJAWS on Windows + Chrome
Setup:
- JAWS 2024+
- Chrome current
- JAWS verbosity: Options → Verbosity → set to Beginner for testing
JAWS virtual cursor commands:
H → next heading
Tab → next interactive element
Insert+F6 → heading list
Insert+F7 → link list
Insert+F5 → form field listKey difference from NVDA: JAWS announces element type after name for many elements. Button "Submit" → JAWS: "Submit button". NVDA: "Submit button" (same, actually consistent here).
Main behavioral difference: JAWS is stricter about aria-modal. If aria-modal="true" is absent on a dialog, JAWS will not virtualize only the dialog — screen reader users can navigate outside the modal using virtual cursor.
VoiceOver on macOS + Safari
Setup:
- System Preferences → Accessibility → VoiceOver → Enable (or Cmd+F5)
- Web rotor: VO+U — lists headings, links, form controls
VO modifier key: Ctrl+Option (abbreviated VO)
Navigation:
VO+Right/Left → next/previous element
VO+Shift+Down → interact with element
VO+Shift+Up → stop interacting
VO+Space → activate element
VO+U → rotor
Tab → next focusable elementTesting live regions with VoiceOver: Live regions are announced by VO when they change. The announcement is appended to the current read queue. Use the Web Console to trigger updates while VoiceOver is active and listen for announcements.
VoiceOver on iOS + Safari
Enable: Settings → Accessibility → VoiceOver
Key gestures:
Swipe right → next element
Swipe left → previous element
Double-tap → activate
Three-finger swipe → scroll
Two-finger Z → escape (dismiss modal, go back)iOS-specific test cases:
- Touch-target size: elements < 44×44px may not receive VO focus or may be hard to activate
- Custom gestures: any swipe gesture in your app may conflict with VoiceOver swipe navigation
aria-hiddenelements: VoiceOver on iOS sometimes announcesaria-hiddenelements — verify with a real device
Building Your Test Matrix
Priority-based coverage
You don't need to test every page on every screen reader. Prioritize:
| Priority | Criteria | Screen readers |
|---|---|---|
| P1 | Auth flows (login, signup, MFA) | NVDA+FF, JAWS+Chrome, VO+Safari |
| P2 | Core user journey (main workflow) | NVDA+FF, VO+iOS |
| P3 | Forms and data entry | NVDA+FF, JAWS+Chrome |
| P4 | Navigation, landing pages | NVDA+FF only |
Test case template
## Screen Reader Test: [Feature Name]
### Environment
- Screen reader: NVDA 2024.1
- Browser: Firefox 123
- OS: Windows 11
- Date: 2024-03-15
### Test Steps
1. Navigate to [URL]
2. [Action with keyboard/screen reader command]
3. [Expected announcement]
### Expected behavior
- Focus moves to [element] when [trigger]
- Announcement: "[exact text expected]"
- Tab order: [describe expected sequence]
### Actual behavior
- [ ] Focus management correct
- [ ] Announcements correct
- [ ] Keyboard navigation correct
### NotesAutomated Screen Reader Testing
True automated screen reader testing is not fully solved, but there are partial solutions:
Guidepup / screen-reader npm package
import { virtual } from '@guidepup/virtual-screen-reader';
import { render } from '@testing-library/react';
test('Dialog announces correctly', async () => {
const { getByRole } = render(<ConfirmDialog open={true} title="Delete item" />);
await virtual.start({ container: document.body });
// Navigate to dialog
await virtual.navigate('dialog');
// Read current element
const spoken = await virtual.spokenPhraseLog();
expect(spoken).toContain('Delete item');
expect(spoken).toContain('dialog');
await virtual.stop();
});Guidepup's virtual screen reader simulates ARIA tree traversal but not real screen reader rendering. Use it for ARIA semantic testing in CI; use real screen readers for interaction testing.
aria-query for tree validation
import { roles } from 'aria-query';
import { computeAccessibleName } from 'dom-accessibility-api';
// Verify accessible name computation matches expectation
function testAccessibleName(element, expected) {
const name = computeAccessibleName(element);
if (name !== expected) {
throw new Error(
`Accessible name mismatch.\nExpected: "${expected}"\nGot: "${name}"`
);
}
}
// In tests:
const button = document.querySelector('[data-testid="submit"]');
testAccessibleName(button, 'Submit payment');Screen reader testing is not a checkbox exercise — it requires real assistive technology with real interaction. The matrix above tells you where to focus that effort: NVDA + Firefox for maximum Windows coverage, VoiceOver + Safari for macOS and iOS. The differences between screen readers are real and they cause real user failures. Test the patterns that differ most (modals, live regions, comboboxes) on the readers that diverge most.