Skip to main content

Screen Reader Optimization: Audio & Visual Accessibility

Medium•

Screen reader optimization makes content accessible to blind users - test with actual screen readers.

Quick Decision Guide

Screen Reader Optimization:

1. Text alternatives: Alt text, aria-label, aria-describedby 2. Proper structure: Heading hierarchy (h1→h2→h3) 3. Live regions: Announce dynamic updates 4. Skip links: Jump to main content 5. Test: Use VoiceOver/NVDA

Visual Accessibility:

1. Color contrast: 4.5:1 minimum 2. Resizable text: Support 200% zoom 3. Motion: Respect prefers-reduced-motion 4. Focus visible: Clear indicators

Result: Usable by blind users and those with low vision.

Text Alternatives

Images

<!-- ✅ Descriptive alt text -->
<img 
  src="chart.png" 
  alt="Bar chart showing 30% sales increase in Q4"
/>

<!-- ✅ Decorative images -->
<img src="decoration.svg" alt="" role="presentation" />

<!-- ✅ Complex images with detailed description -->
<img 
  src="complex-diagram.png" 
  alt="System architecture diagram"
  aria-describedby="diagram-description"
/>
<div id="diagram-description">
  The diagram shows three main components:
  1. Frontend (React app)
  2. API Gateway (Node.js)
  3. Database (PostgreSQL)
  ...
</div>

Icon Buttons

// ❌ Bad: Icon only, no text
<button>
  <TrashIcon />
</button>

// ✅ Good: aria-label
<button aria-label="Delete item">
  <TrashIcon />
</button>

// ✅ Better: Visible text + icon
<button>
  <TrashIcon /> Delete
</button>

// ✅ Alternative: Tooltip with aria-labelledby
<button aria-labelledby="delete-tooltip">
  <TrashIcon />
  <span id="delete-tooltip" className="tooltip">
    Delete item
  </span>
</button>

Links

<!-- ❌ Bad: Ambiguous link text -->
<a href="/docs">Click here</a>

<!-- ✅ Good: Descriptive link text -->
<a href="/docs">Read documentation</a>

<!-- ❌ Bad: Icon link without label -->
<a href="/settings">
  <SettingsIcon />
</a>

<!-- ✅ Good: aria-label -->
<a href="/settings" aria-label="Settings">
  <SettingsIcon />
</a>

Heading Structure

Proper Hierarchy

<!-- ❌ Bad: Skipping levels -->
<h1>Page Title</h1>
<h4>Section Title</h4>  <!-- Skipped h2, h3 -->

<!-- ✅ Good: Logical hierarchy -->
<h1>Page Title</h1>
  <h2>Main Section</h2>
    <h3>Subsection</h3>
    <h3>Another Subsection</h3>
  <h2>Another Main Section</h2>
    <h3>Subsection</h3>

One h1 Per Page

function Page() {
  return (
    <>
      <h1>Dashboard</h1>  {/* One h1 per page */}
      
      <section>
        <h2>Recent Activity</h2>
        {/* content */}
      </section>
      
      <section>
        <h2>Statistics</h2>
        <h3>This Month</h3>
        <h3>Last Month</h3>
      </section>
    </>
  );
}

Screen Reader Navigation

Screen reader users navigate by headings:

•h1: Page title
•h2: Main sections
•h3: Subsections
•Etc.

Proper hierarchy helps users understand page structure.

Live Regions & Dynamic Content

Announce Changes

// Alert (interrupts immediately)
function FormSuccess() {
  return (
    <div role="alert">
      Form submitted successfully!
    </div>
  );
}

// Status (polite, waits for user to finish)
function LoadingIndicator({ isLoading }) {
  return (
    <div role="status" aria-live="polite">
      {isLoading ? 'Loading...' : 'Content loaded'}
    </div>
  );
}

// Log (for chat, notifications)
function ChatMessages({ messages }) {
  return (
    <div role="log" aria-live="polite">
      {messages.map(msg => (
        <div key={msg.id}>{msg.text}</div>
      ))}
    </div>
  );
}

Form Validation

function EmailInput() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  
  return (
      <div>
      <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        aria-invalid={!!error}
        aria-describedby={error ? "email-error" : undefined}
      />
      {error && (
        <div 
          id="email-error" 
          role="alert"  {/* Announces immediately */}
          className="error"
        >
          {error}
        </div>
      )}
    </div>
  );
}

Loading States

function DataTable({ isLoading, data }) {
  if (isLoading) {
    return (
      <div role="status" aria-live="polite">
        <Spinner />
        <span>Loading data...</span>
      </div>
    );
  }
  
  return (
    <table>
      <caption>Sales data for Q4</caption>
      {/* table content */}
    </table>
  );
}

Visual Accessibility

Color Contrast

/* ❌ Bad: Low contrast (2.1:1) */
.text {
  color: #999;
  background: #fff;
}

/* ✅ Good: High contrast (7:1) */
.text {
  color: #333;
  background: #fff;
}

/* Check all states */
.button {
  background: #0070f3;  /* 4.5:1 with white text ✅ */
}

.button:disabled {
  background: #ccc;  /* Still needs 4.5:1 */
  color: #666;
}

Resizable Text

/* ✅ Use relative units */
body {
  font-size: 16px;  /* Base size */
}

h1 {
  font-size: 2rem;  /* Scales with user's font size */
}

/* ❌ Avoid fixed heights that break on zoom */
.container {
  height: 400px;  /* Text may overflow */
}

/* ✅ Use min-height or auto */
.container {
  min-height: 400px;
  height: auto;
}

Motion & Animation

/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* Or disable specific animations */
@media (prefers-reduced-motion: reduce) {
  .slide-in {
    animation: none;
    opacity: 1;
  }
}
// React
function AnimatedComponent() {
  const prefersReducedMotion = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;
  
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: prefersReducedMotion ? 0 : 0.3 }}
    >
      Content
    </motion.div>
  );
}

Focus Indicators

/* ✅ Visible focus */
button:focus-visible {
  outline: 2px solid #0070f3;
  outline-offset: 2px;
}

/* ✅ High contrast mode support */
@media (prefers-contrast: high) {
  button {
    border: 2px solid currentColor;
  }
}

Testing with Screen Readers

VoiceOver (Mac)

Start: Cmd+F5

Navigate: VO+Right/Left (VO = Ctrl+Option)

Read all: VO+A

Headings list: VO+U, then Left/Right arrows

Stop: Ctrl

NVDA (Windows, Free)

Download: https://www.nvaccess.org/

Start: Ctrl+Alt+N

Navigate: Arrow keys

Read all: Insert+Down

Elements list: Insert+F7

Testing Checklist

✅ Navigate with screen reader only

•Can you understand the page?
•Is all content announced?
•Do images have meaningful alt text?

✅ Heading navigation

•Can you jump between sections with headings?
•Is hierarchy logical?

✅ Forms

•Are labels announced?
•Are errors announced?
•Can you fill out form?

✅ Dynamic content

•Are updates announced?
•Do live regions work?

✅ Interactive elements

•Are buttons/links clearly labeled?
•Is current state announced (expanded/collapsed)?

Common Issues

<!-- ❌ Screen reader reads: "Button" (no label) -->
<button><TrashIcon /></button>

<!-- ✅ Screen reader reads: "Delete item, button" -->
<button aria-label="Delete item"><TrashIcon /></button>

<!-- ❌ Screen reader reads: "Graphic" (no alt) -->
<img src="logo.png" />

<!-- ✅ Screen reader reads: "Company logo" -->
<img src="logo.png" alt="Company logo" />

<!-- ❌ Hidden from screen readers -->
<div aria-hidden="true">Important content</div>

<!-- ✅ Visible to screen readers -->
<div>Important content</div>