Accessibility ensures everyone can use your application, including people with disabilities. This guide covers WCAG guidelines, ARIA patterns, and practical implementation techniques.
WCAG Principles (POUR)#
1. Perceivable#
Content must be presentable in ways users can perceive:
1<!-- Provide text alternatives for images -->
2<img src="chart.png" alt="Sales increased 25% from Q1 to Q2 2024">
3
4<!-- Don't rely on color alone -->
5<span class="status error">
6 <span class="icon" aria-hidden="true">✕</span>
7 Error: Invalid email address
8</span>
9
10<!-- Provide captions for videos -->
11<video controls>
12 <source src="demo.mp4" type="video/mp4">
13 <track kind="captions" src="captions.vtt" srclang="en" label="English">
14</video>2. Operable#
Interface must be operable by all users:
1<!-- Keyboard accessible -->
2<button onclick="submitForm()">Submit</button>
3
4<!-- Skip navigation link -->
5<a href="#main-content" class="skip-link">Skip to main content</a>
6
7<!-- Sufficient time for interactions -->
8<div role="alert" aria-live="polite">
9 Session expires in 5 minutes.
10 <button onclick="extendSession()">Extend session</button>
11</div>3. Understandable#
Content and operation must be understandable:
1<!-- Specify page language -->
2<html lang="en">
3
4<!-- Label form inputs -->
5<label for="email">Email address</label>
6<input type="email" id="email" name="email" required>
7
8<!-- Provide error guidance -->
9<div class="error" id="email-error" role="alert">
10 Please enter a valid email address (e.g., name@example.com)
11</div>4. Robust#
Content must be robust for various technologies:
1<!-- Use semantic HTML -->
2<nav aria-label="Main navigation">
3 <ul>
4 <li><a href="/">Home</a></li>
5 <li><a href="/about">About</a></li>
6 </ul>
7</nav>
8
9<!-- Valid HTML -->
10<button type="submit">Send</button>
11<!-- Not: <div onclick="send()">Send</div> -->Semantic HTML#
Document Structure#
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Page Title - Site Name</title>
7</head>
8<body>
9 <a href="#main" class="skip-link">Skip to main content</a>
10
11 <header>
12 <nav aria-label="Main">
13 <!-- Navigation -->
14 </nav>
15 </header>
16
17 <main id="main">
18 <h1>Page Title</h1>
19
20 <article>
21 <h2>Article Title</h2>
22 <p>Content...</p>
23 </article>
24
25 <aside aria-label="Related content">
26 <!-- Sidebar -->
27 </aside>
28 </main>
29
30 <footer>
31 <!-- Footer content -->
32 </footer>
33</body>
34</html>Heading Hierarchy#
1<!-- Correct: Sequential headings -->
2<h1>Main Title</h1>
3 <h2>Section 1</h2>
4 <h3>Subsection 1.1</h3>
5 <h3>Subsection 1.2</h3>
6 <h2>Section 2</h2>
7 <h3>Subsection 2.1</h3>
8
9<!-- Wrong: Skipping levels -->
10<h1>Main Title</h1>
11 <h3>Section</h3> <!-- Skipped h2 -->ARIA Patterns#
Live Regions#
1<!-- Polite: Announced when idle -->
2<div role="status" aria-live="polite">
3 3 items in your cart
4</div>
5
6<!-- Assertive: Announced immediately -->
7<div role="alert" aria-live="assertive">
8 Error: Payment failed. Please try again.
9</div>
10
11<!-- React implementation -->
12function Notification({ message, type }) {
13 return (
14 <div
15 role={type === 'error' ? 'alert' : 'status'}
16 aria-live={type === 'error' ? 'assertive' : 'polite'}
17 >
18 {message}
19 </div>
20 );
21}Modal Dialogs#
1function Modal({ isOpen, onClose, title, children }) {
2 const modalRef = useRef<HTMLDivElement>(null);
3
4 useEffect(() => {
5 if (isOpen) {
6 // Focus the modal when opened
7 modalRef.current?.focus();
8
9 // Trap focus inside modal
10 const handleTab = (e: KeyboardEvent) => {
11 if (e.key === 'Tab') {
12 const focusable = modalRef.current?.querySelectorAll(
13 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
14 );
15 if (focusable) {
16 const first = focusable[0] as HTMLElement;
17 const last = focusable[focusable.length - 1] as HTMLElement;
18
19 if (e.shiftKey && document.activeElement === first) {
20 e.preventDefault();
21 last.focus();
22 } else if (!e.shiftKey && document.activeElement === last) {
23 e.preventDefault();
24 first.focus();
25 }
26 }
27 }
28 if (e.key === 'Escape') {
29 onClose();
30 }
31 };
32
33 document.addEventListener('keydown', handleTab);
34 return () => document.removeEventListener('keydown', handleTab);
35 }
36 }, [isOpen, onClose]);
37
38 if (!isOpen) return null;
39
40 return (
41 <div
42 className="modal-overlay"
43 onClick={onClose}
44 aria-hidden="true"
45 >
46 <div
47 ref={modalRef}
48 role="dialog"
49 aria-modal="true"
50 aria-labelledby="modal-title"
51 tabIndex={-1}
52 onClick={e => e.stopPropagation()}
53 >
54 <h2 id="modal-title">{title}</h2>
55 {children}
56 <button onClick={onClose}>Close</button>
57 </div>
58 </div>
59 );
60}Tabs#
1function Tabs({ tabs }) {
2 const [activeIndex, setActiveIndex] = useState(0);
3
4 const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
5 let newIndex = index;
6
7 switch (e.key) {
8 case 'ArrowRight':
9 newIndex = (index + 1) % tabs.length;
10 break;
11 case 'ArrowLeft':
12 newIndex = (index - 1 + tabs.length) % tabs.length;
13 break;
14 case 'Home':
15 newIndex = 0;
16 break;
17 case 'End':
18 newIndex = tabs.length - 1;
19 break;
20 default:
21 return;
22 }
23
24 e.preventDefault();
25 setActiveIndex(newIndex);
26 };
27
28 return (
29 <div>
30 <div role="tablist" aria-label="Content tabs">
31 {tabs.map((tab, index) => (
32 <button
33 key={tab.id}
34 role="tab"
35 id={`tab-${tab.id}`}
36 aria-selected={index === activeIndex}
37 aria-controls={`panel-${tab.id}`}
38 tabIndex={index === activeIndex ? 0 : -1}
39 onClick={() => setActiveIndex(index)}
40 onKeyDown={(e) => handleKeyDown(e, index)}
41 >
42 {tab.label}
43 </button>
44 ))}
45 </div>
46
47 {tabs.map((tab, index) => (
48 <div
49 key={tab.id}
50 role="tabpanel"
51 id={`panel-${tab.id}`}
52 aria-labelledby={`tab-${tab.id}`}
53 hidden={index !== activeIndex}
54 tabIndex={0}
55 >
56 {tab.content}
57 </div>
58 ))}
59 </div>
60 );
61}Forms#
Accessible Form Pattern#
1function ContactForm() {
2 const [errors, setErrors] = useState<Record<string, string>>({});
3
4 return (
5 <form onSubmit={handleSubmit} noValidate>
6 <div className="form-group">
7 <label htmlFor="name">
8 Name <span aria-hidden="true">*</span>
9 <span className="visually-hidden">(required)</span>
10 </label>
11 <input
12 type="text"
13 id="name"
14 name="name"
15 required
16 aria-required="true"
17 aria-invalid={!!errors.name}
18 aria-describedby={errors.name ? 'name-error' : undefined}
19 />
20 {errors.name && (
21 <div id="name-error" className="error" role="alert">
22 {errors.name}
23 </div>
24 )}
25 </div>
26
27 <fieldset>
28 <legend>Contact preference</legend>
29 <div>
30 <input type="radio" id="email-pref" name="contact" value="email" />
31 <label htmlFor="email-pref">Email</label>
32 </div>
33 <div>
34 <input type="radio" id="phone-pref" name="contact" value="phone" />
35 <label htmlFor="phone-pref">Phone</label>
36 </div>
37 </fieldset>
38
39 <button type="submit">Send message</button>
40 </form>
41 );
42}Error Handling#
1function FormWithValidation() {
2 const [errors, setErrors] = useState<string[]>([]);
3 const errorSummaryRef = useRef<HTMLDivElement>(null);
4
5 const handleSubmit = (e: React.FormEvent) => {
6 e.preventDefault();
7 const newErrors = validate(formData);
8
9 if (newErrors.length > 0) {
10 setErrors(newErrors);
11 // Focus error summary
12 errorSummaryRef.current?.focus();
13 }
14 };
15
16 return (
17 <form onSubmit={handleSubmit}>
18 {errors.length > 0 && (
19 <div
20 ref={errorSummaryRef}
21 role="alert"
22 tabIndex={-1}
23 className="error-summary"
24 >
25 <h2>There are {errors.length} errors in this form</h2>
26 <ul>
27 {errors.map((error, index) => (
28 <li key={index}>
29 <a href={`#${error.field}`}>{error.message}</a>
30 </li>
31 ))}
32 </ul>
33 </div>
34 )}
35 {/* Form fields */}
36 </form>
37 );
38}Focus Management#
Visible Focus Styles#
1/* Never remove focus outlines completely */
2:focus {
3 outline: 2px solid #005fcc;
4 outline-offset: 2px;
5}
6
7/* Use :focus-visible for keyboard-only focus */
8:focus:not(:focus-visible) {
9 outline: none;
10}
11
12:focus-visible {
13 outline: 2px solid #005fcc;
14 outline-offset: 2px;
15}
16
17/* Skip link */
18.skip-link {
19 position: absolute;
20 top: -40px;
21 left: 0;
22 padding: 8px;
23 background: #000;
24 color: #fff;
25 z-index: 100;
26}
27
28.skip-link:focus {
29 top: 0;
30}Managing Focus in SPAs#
1function usePageFocus(pageTitle: string) {
2 const headingRef = useRef<HTMLHeadingElement>(null);
3
4 useEffect(() => {
5 // Update document title
6 document.title = pageTitle;
7
8 // Focus main heading
9 headingRef.current?.focus();
10
11 // Announce page change
12 const announcement = document.createElement('div');
13 announcement.setAttribute('role', 'status');
14 announcement.setAttribute('aria-live', 'polite');
15 announcement.className = 'visually-hidden';
16 announcement.textContent = `Navigated to ${pageTitle}`;
17 document.body.appendChild(announcement);
18
19 return () => {
20 document.body.removeChild(announcement);
21 };
22 }, [pageTitle]);
23
24 return headingRef;
25}
26
27function Page({ title, children }) {
28 const headingRef = usePageFocus(title);
29
30 return (
31 <main>
32 <h1 ref={headingRef} tabIndex={-1}>{title}</h1>
33 {children}
34 </main>
35 );
36}Testing Accessibility#
Automated Testing#
1// Jest + Testing Library
2import { axe, toHaveNoViolations } from 'jest-axe';
3
4expect.extend(toHaveNoViolations);
5
6test('form is accessible', async () => {
7 const { container } = render(<ContactForm />);
8 const results = await axe(container);
9 expect(results).toHaveNoViolations();
10});
11
12// Playwright
13test('page meets accessibility standards', async ({ page }) => {
14 await page.goto('/');
15
16 const accessibilityScanResults = await new AxeBuilder({ page })
17 .withTags(['wcag2a', 'wcag2aa'])
18 .analyze();
19
20 expect(accessibilityScanResults.violations).toEqual([]);
21});Manual Testing Checklist#
- Navigate using keyboard only (Tab, Enter, Space, Arrows)
- Test with screen reader (VoiceOver, NVDA, JAWS)
- Zoom to 200% - ensure content remains usable
- Use high contrast mode
- Disable images - check alt text adequacy
- Check color contrast ratios (4.5:1 for text)
Conclusion#
Accessibility benefits everyone. Start with semantic HTML, add ARIA only when needed, and test with real assistive technologies. Build accessibility into your development process from the beginning.