PatternsAccessibility
Screen Reader Testing
Test your app with NVDA, JAWS, and VoiceOver effectively
Screen readers are the primary way blind users navigate the web. Manual testing with real screen readers is critical—automated tests miss 70% of accessibility issues.
Major Screen Readers
Windows:
- JAWS (40% market share) - Commercial ($1000+)
- NVDA (30% market share) - Free ⭐
Mac/iOS:
- VoiceOver (20% market share) - Built-in ⭐
Android:
- TalkBack (10% market share) - Built-inNVDA Setup (Windows)
Installation
# Download from nvaccess.org
# Free and open-source
# Most popular free screen readerBasic Commands
NVDA + Q: Quit NVDA
NVDA + N: Open menu
NVDA + T: Read title
NVDA + Space: Browse/Focus mode toggle
Navigation:
- H: Next heading
- Shift + H: Previous heading
- 1-6: Headings by level
- K: Next link
- F: Next form field
- B: Next button
- T: Next table
Reading:
- NVDA + Down: Read next line
- NVDA + Up: Read previous line
- Ctrl: Stop reading
- NVDA + A: Read allTesting Checklist
// Test landmarks navigation
export function testLandmarks() {
// NVDA + D to list landmarks
// Should announce: "navigation", "main", "complementary", "contentinfo"
return (
<>
<nav aria-label="Main navigation">...</nav>
<main>...</main>
<aside>...</aside>
<footer>...</footer>
</>
);
}
// Test heading structure
export function testHeadings() {
// Press H to jump between headings
// Should follow logical order: h1 → h2 → h3
return (
<>
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
</>
);
}
// Test form labels
export function testForm() {
// Navigate with F (form fields)
// Each field should announce its label
return (
<form>
<label htmlFor="email">Email</label>
<input id="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" type="password" required />
</form>
);
}JAWS (Windows)
Basic Commands
Insert + F7: Links list
Insert + F5: Form fields list
Insert + F6: Headings list
Navigation:
- H: Next heading
- R: Next region/landmark
- T: Next table
- G: Next graphic
Virtual Cursor:
- Up/Down: Read by line
- Ctrl + Home: Top of page
- Ctrl + End: Bottom of pageTesting Focus
export function FocusableComponent() {
// JAWS announces focus changes
// Test: Tab through all interactive elements
return (
<div>
<button>First</button>
<a href="/page">Link</a>
<input type="text" aria-label="Search" />
<button>Last</button>
</div>
);
}
// Listen for focus order
// Should be: Button → Link → Input → ButtonVoiceOver (Mac)
Enable VoiceOver
Command + F5: Toggle VoiceOver
Command + F5 F5 F5 (triple press): Accessibility shortcutBasic Commands
VO = Control + Option
Navigation:
- VO + Right: Next item
- VO + Left: Previous item
- VO + A: Read all
- VO + H H: Headings menu
- VO + U: Web Rotor (navigation menu)
Web Rotor Tabs:
- Links
- Headings
- Form Controls
- Tables
- LandmarksTesting with Rotor
export function RotorTestComponent() {
// VO + U to open Rotor
// Navigate to Headings tab
// Should see all headings listed
return (
<article>
<h1>Article Title</h1>
<h2>Introduction</h2>
<p>Content...</p>
<h2>Details</h2>
<p>More content...</p>
</article>
);
}
// Test: All headings should appear in Rotor
// Test: Should be in logical orderTesting Dynamic Content
Live Regions
export function LiveRegionTest() {
const [message, setMessage] = useState('');
// Test: NVDA/JAWS should announce message changes
// VoiceOver: Should announce automatically
return (
<div>
<button onClick={() => setMessage('Item added to cart')}>
Add to Cart
</button>
<div
role="status"
aria-live="polite"
aria-atomic="true"
>
{message}
</div>
</div>
);
}
// Expected: Screen reader announces "Item added to cart"
// No need to focus the messageLoading States
export function LoadingTest() {
const [loading, setLoading] = useState(false);
// Test: Screen reader should announce loading state
return (
<div>
<button
onClick={() => setLoading(true)}
aria-busy={loading}
>
{loading ? 'Loading...' : 'Load Data'}
</button>
{loading && (
<div role="status" aria-live="polite">
Loading data, please wait...
</div>
)}
</div>
);
}
// Expected: "Loading data, please wait..." announcementTesting SPAs
Route Changes
// Announce route changes to screen readers
export function RouteAnnouncer() {
const location = useLocation();
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
// Announce page title on route change
const title = document.title;
setAnnouncement(`Navigated to ${title}`);
}, [location]);
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
);
}
// Test: Navigate between routes
// Expected: Screen reader announces each pageFocus Management
export function FocusManagementTest() {
const mainRef = useRef<HTMLElement>(null);
const location = useLocation();
useEffect(() => {
// Move focus to main content on route change
mainRef.current?.focus();
}, [location]);
return (
<main
ref={mainRef}
tabIndex={-1}
style={{ outline: 'none' }}
>
<h1>Page Content</h1>
</main>
);
}
// Test: Navigate to new route
// Expected: Focus moves to main contentCommon Issues to Test
Missing Alt Text
// ❌ BAD: Screen reader says "image"
<img src="/photo.jpg" />
// ✅ GOOD: Descriptive alt text
<img src="/photo.jpg" alt="Team celebrating product launch" />
// ✅ GOOD: Decorative image
<img src="/decoration.jpg" alt="" role="presentation" />Button vs Link
// ❌ BAD: Link looks like button
<a href="#" onClick={handleClick}>Click me</a>
// ✅ GOOD: Use button for actions
<button onClick={handleClick}>Click me</button>
// ✅ GOOD: Use link for navigation
<a href="/page">Go to page</a>Form Errors
export function FormErrorTest() {
const [error, setError] = useState('');
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
<div id="email-error" role="alert">
{error}
</div>
)}
</div>
);
}
// Test: Trigger error
// Expected: Screen reader announces error messageTesting Checklist
- Navigate entire site with keyboard only
- Test all interactive elements
- Verify heading structure (H key)
- Check landmark regions (D key in NVDA)
- Test form labels and errors
- Verify alt text on images
- Test dynamic content announcements
- Check focus management in SPA
- Test modal dialogs
- Verify loading states announced
Automated + Manual
// Automated testing catches ~30%
import { axe } from 'jest-axe';
test('no accessibility violations', async () => {
const { container } = render(<Component />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Manual testing catches ~70%
// Use real screen readers!Best Practices
- Test with real users if possible
- Use multiple screen readers (NVDA + VoiceOver minimum)
- Test keyboard navigation first
- Verify announcements for dynamic content
- Check forms thoroughly (labels, errors, validation)
- Test modals and overlays (focus trap, announcements)
- Verify landmarks help navigation
- Test mobile with VoiceOver/TalkBack
- Document issues with screen reader + version
- Regular testing in development cycle
Screen reader testing is essential—automated tools only catch 30% of issues. Test manually with real screen readers!