Practical guide to testing web applications with screen readers for comprehensive accessibility validation.
- Validating screen reader compatibility
- Testing ARIA implementations
- Debugging assistive technology issues
- Verifying form accessibility
- Testing dynamic content announcements
- Ensuring navigation accessibility
| Screen Reader | Platform | Browser |
|---|
VoiceOver | macOS/iOS | Safari |
NVDA | Windows | Firefox/Chrome |
JAWS | Windows | Chrome |
TalkBack | Android | Chrome |
Narrator | Windows | Edge |
Minimum Coverage:
1. NVDA + Firefox (Windows)
2. VoiceOver + Safari (macOS)
3. VoiceOver + Safari (iOS)
Comprehensive Coverage:
+ JAWS + Chrome (Windows)
+ TalkBack + Chrome (Android)
+ Narrator + Edge (Windows)
| Mode | Purpose | When Used |
|---|
Browse/Virtual | Read content | Default reading |
Focus/Forms | Interact with controls | Filling forms |
Application | Custom widgets | ARIA applications |
Before testing on macOS, read VoiceOver setup, commands, and checklists.
For the required iOS Safari coverage, read VoiceOver touch setup and gestures.
Before testing with NVDA, read NVDA setup, modes, commands, and test scripts.
Before testing with JAWS or TalkBack, read platform setup and commands.
These reference guides are for the human tester. Get the user's confirmation before installing software or changing system settings based on them.
<!-- Accessible modal structure -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title" tabindex="-1">Confirm Delete</h2>
<p id="dialog-desc">This action cannot be undone.</p>
<button>Cancel</button>
<button>Delete</button>
</div>
// Focus management
function openModal(modal) {
// Store last focused element
lastFocus = document.activeElement;
// Move focus to modal
modal.querySelector("h2").focus();
// Trap focus
modal.addEventListener("keydown", trapFocus);
}
function closeModal(modal) {
// Return focus
lastFocus.focus();
}
function trapFocus(e) {
if (e.key === "Tab") {
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
if (e.key === "Escape") {
closeModal(modal);
}
}
This is a simplified sketch, not copy-ready code. In real code, keep the modal in scope inside the keydown handler, declare the stored focus variable, remove the keydown listener and hide the dialog on close, and check that the stored element still exists before returning focus. The focusable list should also leave out disabled controls and hidden or inert elements. Test the result with a real screen reader before relying on it.
<!-- Status messages (polite) -->
<div role="status" aria-live="polite" aria-atomic="true">
<!-- Content updates will be announced after current speech -->
</div>
<!-- Alerts (assertive) -->
<div role="alert" aria-live="assertive">
<!-- Content updates interrupt current speech -->
</div>
<!-- Progress updates -->
<div
role="progressbar"
aria-valuenow="75"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Upload progress"
></div>
<!-- Log (additions only) -->
<div role="log" aria-live="polite" aria-relevant="additions">
<!-- New messages announced, removals not -->
</div>
<div role="tablist" aria-label="Product information">
<button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">
Description
</button>
<button
role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1"
>
Reviews
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Product description content...
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Reviews content...
</div>
// Tab keyboard navigation
tablist.addEventListener("keydown", (e) => {
const tabs = [...tablist.querySelectorAll('[role="tab"]')];
const index = tabs.indexOf(document.activeElement);
let newIndex;
switch (e.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return;
}
tabs[newIndex].focus();
activateTab(tabs[newIndex]);
e.preventDefault();
});
The activateTab call is a placeholder you supply. It should update aria-selected on each tab, leave only the active tab in the tab order using tabindex, and show the matching panel while hiding the others.
The helper below is only a rough approximation of what a screen reader announces. The aria-labelledby attribute holds ID references rather than text, and it takes precedence over aria-label. The helper also ignores label elements, alt text and title, reports tag names that are not the same as implicit roles, and ignores aria-hidden on the element and its ancestors. Use the accessibility tree in the browser developer tools for the real computed name and role.
// Log what screen reader sees
function logAccessibleName(element) {
const computed = window.getComputedStyle(element);
console.log({
role: element.getAttribute("role") || element.tagName,
name:
element.getAttribute("aria-label") ||
element.getAttribute("aria-labelledby") ||
element.textContent,
state: {
expanded: element.getAttribute("aria-expanded"),
selected: element.getAttribute("aria-selected"),
checked: element.getAttribute("aria-checked"),
disabled: element.disabled,
},
visible: computed.display !== "none" && computed.visibility !== "hidden",
});
}
- Test with actual screen readers - Not just simulators
- Use semantic HTML first - ARIA is supplemental
- Test in browse and focus modes - Different experiences
- Verify focus management - Especially for SPAs
- Test keyboard only first - Foundation for SR testing
- Don't assume one SR is enough - Test multiple
- Don't ignore mobile - Growing user base
- Don't test only happy path - Test error states
- Don't skip dynamic content - Most common issues
- Don't rely on visual testing - Different experience