Testing with screen readers is the only way to truly know if your React app is accessible. Automated tools catch only 30-40% of accessibility issues. In this guide, you'll learn how to test with NVDA (Windows) and VoiceOver (Mac), understand what to listen for, and fix common issues in React components.
Why Screen Reader Testing is Essential
Here's why you can't rely on automated tools alone:
- User experience - Automated tools can't detect confusing experiences
- Context matters - What sounds good on paper might be awful to hear
- Dynamic content - React's dynamic updates need manual verification
- Real-world validation - See how actual users experience your app
- Legal protection - Manual testing is often required for compliance
Getting Started with NVDA (Windows)
NVDA is a free, open-source screen reader for Windows. Perfect for testing:
Installing NVDA
1. Download from https://www.nvaccess.org/download/
2. Run installer (choose "Install NVDA on this computer")
3. NVDA starts automatically
4. Listen for the welcome message
Essential NVDA Commands
| Command | Action |
|---|---|
| Ctrl | Stop speaking |
| NVDA + Q | Quit NVDA |
| NVDA + Space | Toggle focus/browse mode |
| H | Next heading |
| Shift + H | Previous heading |
| D | Next landmark |
| K | Next link |
| B | Next button |
| F | Next form field |
| NVDA + F7 | Elements list |
| NVDA + Down Arrow | Say all (read from cursor) |
| Insert + F1 | Help menu |
Note: NVDA key is usually Insert or Caps Lock (configurable)
NVDA Testing Workflow
// 1. Start NVDA
// 2. Open your React app in browser
// 3. Press NVDA + Space to enter browse mode
// 4. Test navigation:
// - Press H repeatedly to navigate headings
// - Press D to jump between landmarks
// - Press K to move through links
// - Press F to visit form fields
// 5. Test interactive elements:
// - Tab to buttons, verify they're announced
// - Fill out forms, listen to labels and errors
// - Open modals, verify focus management
// 6. Test dynamic content:
// - Make changes trigger, verify announcements
// - Check live regions work correctly
Getting Started with VoiceOver (Mac)
VoiceOver is built into macOS. No installation needed!
Enabling VoiceOver
1. Cmd + F5 (or touch Touch ID 3 times)
2. VoiceOver starts speaking
3. First time: complete Quick Start tutorial
4. Cmd + F5 to turn off
Essential VoiceOver Commands
| Command | Action |
|---|---|
| Ctrl | Stop speaking |
| Cmd + F5 | Turn on/off VoiceOver |
| VO + Right Arrow | Next item |
| VO + Left Arrow | Previous item |
| VO + Cmd + H | Next heading |
| VO + Cmd + J | Next form control |
| VO + Cmd + L | Next link |
| VO + U | Rotor (elements list) |
| VO + A | Read all |
| VO + Space | Click/activate |
| VO + Shift + Down | Enter group |
| VO + Shift + Up | Exit group |
Note: VO = Ctrl + Option (VoiceOver modifier keys)
What to Test in Your React Components
1. Headings and Document Structure
// Bad - No heading hierarchy
<div>
<h1>Page Title</h1>
<h3>Subsection</h3> {/* Skips h2 */}
<h2>Another section</h2> {/* Wrong order */}
</div>
// Good - Proper hierarchy
<div>
<h1>Page Title</h1>
<h2>Main Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>
</div>
// Testing:
// 1. Press H (or VO + Cmd + H) to navigate headings
// 2. Verify hierarchy makes sense
// 3. Check no levels are skipped
2. Landmarks and Regions
// Good landmark structure
function Layout() {
return (
<>
<header>
<nav aria-label="Main navigation">{/* Nav */}</nav>
</header>
<main>
<section aria-labelledby="about-heading">
<h2 id="about-heading">About Us</h2>
</section>
</main>
<aside aria-label="Sidebar">{/* Sidebar */}</aside>
<footer>{/* Footer */}</footer>
</>
);
}
// Testing:
// NVDA: Press D to jump between landmarks
// VoiceOver: VO + U, then use Left/Right arrows to navigate
// Verify: All major sections are announced clearly
3. Forms and Inputs
// What screen readers should announce
<form>
<label htmlFor="email">
Email address
<span aria-label="required">*</span>
</label>
<input
id="email"
type="email"
aria-required="true"
aria-describedby="email-hint"
aria-invalid={hasError}
/>
<div id="email-hint">We'll never share your email</div>
{hasError && (
<div role="alert">Please enter a valid email</div>
)}
</form>
// What you should hear:
// "Email address, required, edit text"
// "We'll never share your email"
// (if error) "Please enter a valid email"
// Testing checklist:
// ✅ Label is read before input
// ✅ Required status is announced
// ✅ Helper text is read
// ✅ Error messages are announced immediately
// ✅ Can fill out form using Tab and keyboard only
4. Buttons and Links
// Bad - No context
<button onClick={handleDelete}>Delete</button>
<a href="/learn-more">Click here</a>
// Good - Clear labels
<button onClick={handleDelete} aria-label="Delete product Apple iPhone">
Delete
</button>
<a href="/learn-more">Learn more about our accessibility features</a>
// Icon-only buttons need labels
<button aria-label="Close modal" onClick={close}>
<CloseIcon aria-hidden="true" />
</button>
// What you should hear:
// "Delete product Apple iPhone, button"
// "Learn more about our accessibility features, link"
// "Close modal, button"
// Testing:
// NVDA: Press B for buttons, K for links
// VoiceOver: VO + Cmd + L for links
// Verify: Each has clear, descriptive text
5. Dynamic Content and Live Regions
// Announcement component
function Notification({ message, type }) {
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
aria-atomic="true"
>
{message}
</div>
);
}
// Testing dynamic updates
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<div role="status" aria-live="polite" aria-atomic="true">
Count: {count}
</div>
</div>
);
}
// What to test:
// 1. Click button
// 2. Wait 1-2 seconds
// 3. Verify new count is announced
// 4. Check announcement doesn't interrupt other reading
React-Specific Testing Scenarios
Testing Modal Dialogs
function TestModal() {
// 1. Tab to "Open Modal" button
// 2. Verify: "Open modal, button"
// 3. Press Enter or Space
// 4. Verify: Focus moves to modal
// 5. Verify: Modal title is announced
// 6. Try to Tab - focus should stay in modal
// 7. Press Escape
// 8. Verify: Focus returns to trigger button
return (
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
aria-labelledby="modal-title"
>
<h2 id="modal-title">Confirm Delete</h2>
<p>Are you sure?</p>
<button onClick={handleConfirm}>Yes, delete</button>
<button onClick={handleCancel}>Cancel</button>
</Modal>
);
}
Testing Accordions
function TestAccordion() {
// 1. Tab to accordion button
// 2. Verify: "Section title, button, collapsed"
// 3. Press Enter
// 4. Verify: "Section title, button, expanded"
// 5. Tab to next item
// 6. Verify: Content is read
return (
<div>
<h3>
<button
aria-expanded={isOpen}
aria-controls="panel-1"
>
Section Title
</button>
</h3>
<div id="panel-1" hidden={!isOpen}>
Content goes here
</div>
</div>
);
}
Testing Tabs
function TestTabs() {
// 1. Tab to first tab
// 2. Verify: "Tab 1, tab, 1 of 3, selected"
// 3. Press Right Arrow
// 4. Verify: "Tab 2, tab, 2 of 3"
// 5. Press Space or Enter
// 6. Verify: "Tab 2, tab, selected"
// 7. Tab to panel
// 8. Verify: Panel content is read
return (
<div>
<div role="tablist">
<button
role="tab"
aria-selected={active === 0}
aria-controls="panel-1"
>
Tab 1
</button>
</div>
<div role="tabpanel" id="panel-1">
Panel content
</div>
</div>
);
}
Common Issues and How to Fix Them
Issue 1: "Clickable" Announced for Everything
// Problem: Using divs with onClick
<div onClick={handleClick}>Click me</div>
// Heard: "Click me, clickable" (vague)
// Solution: Use semantic HTML
<button onClick={handleClick}>Click me</button>
// Heard: "Click me, button" (clear)
Issue 2: Icon Buttons with No Labels
// Problem
<button><TrashIcon /></button>
// Heard: "Button" (unhelpful)
// Solution
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// Heard: "Delete item, button"
Issue 3: Form Errors Not Announced
// Problem - Error shown visually only
{error && <div className="error">{error}</div>}
// Solution - Use role="alert"
{error && <div role="alert">{error}</div>}
// Announced immediately when appears
Issue 4: Loading States Not Announced
// Problem
{isLoading && <Spinner />}
// Solution
{isLoading && (
<div role="status" aria-live="polite">
<span className="sr-only">Loading...</span>
<Spinner aria-hidden="true" />
</div>
)}
Screen Reader Testing Checklist
- ✅ Navigate entire app using only screen reader
- ✅ Verify all images have alt text
- ✅ Check all buttons have descriptive labels
- ✅ Confirm form fields have labels
- ✅ Test error messages are announced
- ✅ Verify modals trap focus correctly
- ✅ Check headings follow proper hierarchy
- ✅ Test landmarks are present and labeled
- ✅ Confirm dynamic content is announced
- ✅ Verify skip links work
- ✅ Check focus order makes sense
- ✅ Test with both NVDA and VoiceOver
Debugging Tips
// 1. Use browser DevTools Accessibility tree
// Chrome: DevTools > Elements > Accessibility tab
// 2. Log ARIA attributes in React
useEffect(() => {
const button = buttonRef.current;
console.log({
role: button.getAttribute('role'),
'aria-label': button.getAttribute('aria-label'),
'aria-expanded': button.getAttribute('aria-expanded'),
});
}, []);
// 3. Test one component at a time
// Create isolated test page for complex components
// 4. Record yourself testing
// Listen back to catch issues you missed
Automated Testing to Complement Manual Testing
// Use React Testing Library with jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('component has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Test screen reader announcements
import { render, screen } from '@testing-library/react';
test('error is announced', () => {
render(<Form error="Invalid email" />);
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Invalid email');
});
Resources for Learning More
- WebAIM Screen Reader Survey - User preferences and behavior
- NVDA User Guide - Complete documentation
- VoiceOver User Guide - Apple's official guide
- Screen Reader Basics - Free course from Deque
- A11ycasts - Video series on accessibility
Key Takeaways
- Automated tools only catch 30-40% of accessibility issues
- NVDA (Windows) and VoiceOver (Mac) are free and easy to use
- Test with eyes closed to experience what users hear
- Focus on headings, landmarks, forms, and dynamic content
- React's dynamic nature requires extra attention to announcements
- Common issues: Missing labels, no error announcements, poor focus management
- Manual testing should complement, not replace, automated testing
Screen reader testing might feel awkward at first, but it's the only way to truly understand how accessible your React app is. Start with 15 minutes per week, test one component at a time, and gradually build your skills. Your users with disabilities will thank you!
💡 Pro tip: Join the "blind-users" channel in accessibility-focused Slack communities. Ask real screen reader users to test your app and provide feedback. Their insights are invaluable!