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>