Screen Reader Optimization: Audio & Visual Accessibility
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:
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>
);
}Skip Links & Landmarks
Skip to Main Content
// Skip link (visible on focus)
function Layout() {
return (
<>
<a
href="#main-content"
className="skip-link"
>
Skip to main content
</a>
<header>
<nav>{/* Many links */}</nav>
</header>
<main id="main-content" tabIndex={-1}>
{/* Main content */}
</main>
</>
);
}
// CSS
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
position: static;
/* Or position at top with z-index */
}Landmarks
<header> <!-- banner landmark -->
<nav> <!-- navigation landmark -->
</header>
<main> <!-- main landmark -->
<article>
<section>
</article>
</main>
<aside> <!-- complementary landmark -->
</aside>
<footer> <!-- contentinfo landmark -->
</footer>
<!-- Or with ARIA -->
<div role="banner">
<div role="navigation">
<div role="main">
<div role="complementary">
<div role="contentinfo">Screen reader users can jump between landmarks.
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
✅ Heading navigation
✅ Forms
✅ Dynamic content
✅ Interactive elements
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>