Back to Blog

Screen Reader Testing for React Developers

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!