Web Accessibility (A11Y)
WCAG 2.1 AA compliance guidelines and implementation patterns.
WCAG Principles (POUR)
- Perceivable - Information must be presentable in ways users can perceive
- Operable - Interface must be operable by all users
- Understandable - Information and operation must be understandable
- Robust - Content must be robust enough for assistive technologies
Semantic HTML
Use Correct Elements
<!-- BAD: Div soup -->
<div class="header">
<div class="nav">
<div class="link" onclick="navigate()">Home</div>
</div>
</div>
<!-- GOOD: Semantic elements -->
<header>
<nav aria-label="Main navigation">
<a href="/">Home</a>
</nav>
</header>
Headings Hierarchy
<!-- BAD: Skipping heading levels -->
<h1>Page Title</h1>
<h3>Section</h3> <!-- Skipped h2! -->
<!-- GOOD: Proper hierarchy -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
Landmark Regions
<header role="banner">
<nav role="navigation" aria-label="Main">...</nav>
</header>
<main role="main">
<article>...</article>
<aside role="complementary">...</aside>
</main>
<footer role="contentinfo">...</footer>
Images & Media
Alternative Text
<!-- Informative image -->
<img src="chart.png" alt="Sales increased 25% in Q4 2024">
<!-- Decorative image -->
<img src="divider.png" alt="" role="presentation">
<!-- Complex image -->
<figure>
<img src="diagram.png" alt="System architecture diagram">
<figcaption>
Figure 1: The system consists of a web server,
application server, and database server.
</figcaption>
</figure>
<!-- Logo/link -->
<a href="/">
<img src="logo.png" alt="Company Name - Home">
</a>
Video & Audio
<video controls>
<source src="video.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English">
<track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio descriptions">
</video>
Forms
Labels
<!-- BAD: No label association -->
<input type="email" placeholder="Email">
<!-- GOOD: Explicit label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" required>
<!-- GOOD: Implicit label -->
<label>
Email address
<input type="email" name="email" required>
</label>
Error Messages
<div class="form-group">
<label for="email">Email address</label>
<input
type="email"
id="email"
name="email"
aria-describedby="email-error email-hint"
aria-invalid="true"
required
>
<p id="email-hint" class="hint">We'll never share your email</p>
<p id="email-error" class="error" role="alert">
Please enter a valid email address
</p>
</div>
Fieldsets & Legends
<fieldset>
<legend>Shipping Address</legend>
<label for="street">Street</label>
<input type="text" id="street" name="street">
<!-- More fields -->
</fieldset>
<fieldset>
<legend>Preferred contact method</legend>
<label>
<input type="radio" name="contact" value="email"> Email
</label>
<label>
<input type="radio" name="contact" value="phone"> Phone
</label>
</fieldset>
Keyboard Navigation
Focus Management
/* Never remove focus outline without replacement */
/* BAD */
*:focus { outline: none; }
/* GOOD: Custom focus styles */
:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Focus-visible for keyboard-only focus */
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Tab Order
<!-- Use natural tab order, avoid positive tabindex -->
<button>First</button>
<button>Second</button>
<button>Third</button>
<!-- Remove from tab order if visually hidden -->
<button tabindex="-1" aria-hidden="true">Skip</button>
Skip Links
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>...</header>
<main id="main-content" tabindex="-1">...</main>
</body>
<style>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
padding: 1rem;
background: #000;
color: #fff;
z-index: 9999;
}
</style>
ARIA
ARIA Roles
<!-- Buttons that aren't <button> -->
<div role="button" tabindex="0" aria-pressed="false">Toggle</div>
<!-- Tab interface -->
<div role="tablist">
<button role="tab" aria-selected="true" aria-controls="panel1">Tab 1</button>
<button role="tab" aria-selected="false" aria-controls="panel2">Tab 2</button>
</div>
<div role="tabpanel" id="panel1">Content 1</div>
<div role="tabpanel" id="panel2" hidden>Content 2</div>
<!-- Alert for dynamic content -->
<div role="alert" aria-live="assertive">Form submitted successfully</div>
ARIA States & Properties
<!-- Expanded/collapsed -->
<button aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>...</ul>
<!-- Current page -->
<nav>
<a href="/" aria-current="page">Home</a>
<a href="/about">About</a>
</nav>
<!-- Disabled -->
<button aria-disabled="true">Submit</button>
<!-- Loading -->
<button aria-busy="true">
<span class="spinner" aria-hidden="true"></span>
Loading...
</button>
Live Regions
<!-- Polite announcements (wait for pause) -->
<div aria-live="polite" aria-atomic="true">
3 items in cart
</div>
<!-- Assertive announcements (immediate) -->
<div role="alert" aria-live="assertive">
Error: Payment failed
</div>
<!-- Status updates -->
<div role="status" aria-live="polite">
Showing results 1-10 of 100
</div>
Color & Contrast
Contrast Requirements
- Normal text: 4.5:1 minimum
- Large text (18pt+ or 14pt+ bold): 3:1 minimum
- UI components: 3:1 minimum
/* BAD: Low contrast */
.light-text {
color: #999; /* On white: 2.85:1 - FAIL */
}
/* GOOD: Sufficient contrast */
.text {
color: #595959; /* On white: 7:1 - PASS */
}
Don't Rely on Color Alone
<!-- BAD: Color only -->
<span class="error" style="color: red;">Invalid</span>
<!-- GOOD: Color + icon + text -->
<span class="error">
<svg aria-hidden="true"><!-- Error icon --></svg>
Error: Invalid email address
</span>
Testing
Automated Tools
# axe-core
npx @axe-core/cli https://example.com
# Lighthouse
npx lighthouse https://example.com --only-categories=accessibility
# Pa11y
npx pa11y https://example.com
Manual Testing Checklist
-
Keyboard-only navigation
- Tab through all interactive elements
- Ensure visible focus indicator
- Check logical tab order
- Test all functionality without mouse
-
Screen reader testing
- VoiceOver (macOS): Cmd + F5
- NVDA (Windows): Free download
- Test headings, links, forms, dynamic content
-
Zoom testing
- Zoom to 200%, check layout
- Zoom to 400%, check readability
-
Color/contrast
- Use browser dev tools contrast checker
- Test with color blindness simulators
React Accessibility
// Focus management
const inputRef = useRef(null);
useEffect(() => {
if (showError) {
inputRef.current?.focus();
}
}, [showError]);
// Accessible component
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
tabIndex={-1}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
Common Issues & Fixes
| Issue | Impact | Fix |
|-------|--------|-----|
| Missing alt text | Screen readers can't describe images | Add descriptive alt or alt="" for decorative |
| Missing form labels | Users don't know field purpose | Add <label for=""> |
| Low color contrast | Hard to read for low vision | Increase contrast to 4.5:1 |
| No focus indicator | Keyboard users can't see focus | Add visible :focus styles |
| Mouse-only interactions | Keyboard users can't access | Add keyboard event handlers |
| Auto-playing media | Disorienting, hard to stop | Add controls, don't autoplay |
| Missing page title | Users don't know page context | Add unique <title> |
| Missing language | Screen readers mispronounce | Add <html lang="en"> |