Front-end Engineering Lab

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-in

NVDA Setup (Windows)

Installation

# Download from nvaccess.org
# Free and open-source
# Most popular free screen reader

Basic 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 all

Testing 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 page

Testing 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 → Button

VoiceOver (Mac)

Enable VoiceOver

Command + F5: Toggle VoiceOver
Command + F5 F5 F5 (triple press): Accessibility shortcut

Basic 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
- Landmarks

Testing 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 order

Testing 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 message

Loading 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..." announcement

Testing 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 page

Focus 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 content

Common 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" />
// ❌ 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 message

Testing 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

  1. Test with real users if possible
  2. Use multiple screen readers (NVDA + VoiceOver minimum)
  3. Test keyboard navigation first
  4. Verify announcements for dynamic content
  5. Check forms thoroughly (labels, errors, validation)
  6. Test modals and overlays (focus trap, announcements)
  7. Verify landmarks help navigation
  8. Test mobile with VoiceOver/TalkBack
  9. Document issues with screen reader + version
  10. Regular testing in development cycle

Screen reader testing is essential—automated tools only catch 30% of issues. Test manually with real screen readers!

On this page