React Accessibility (a11y) Guide

Learn to build inclusive React applications that work for everyone. Master WCAG guidelines, ARIA patterns, keyboard navigation, and screen reader support.

Advertisement Space - top-a11y

Google AdSense: horizontal

WCAG 2.1 Core Principles

Perceivable

  • Provide text alternatives for non-text content
  • Provide captions and transcripts for multimedia
  • Ensure sufficient color contrast (4.5:1 for normal text, 3:1 for large text)
  • Don't rely on color alone to convey information
  • Ensure content is readable and understandable

Operable

  • Make all functionality keyboard accessible
  • Give users enough time to read and use content
  • Don't use content that causes seizures
  • Help users navigate and find content
  • Make it easier to use inputs other than keyboard

Understandable

  • Make text readable and understandable
  • Make pages appear and operate predictably
  • Help users avoid and correct mistakes
  • Ensure consistent navigation and identification
  • Provide clear instructions and error messages

Robust

  • Maximize compatibility with assistive technologies
  • Ensure content remains accessible as technologies advance
  • Use valid, well-structured markup
  • Provide name, role, and value for all UI components
  • Ensure status messages are announced

Semantic HTML & ARIA

Build accessible React components with proper HTML semantics and ARIA attributes

Semantic HTML provides meaning to screen readers and assistive technologies. ARIA (Accessible Rich Internet Applications) attributes enhance accessibility when semantic HTML alone is insufficient.

Implementation

1// Semantic HTML and ARIA in React Components
2import React, { useState, useId } from 'react';
3
4// 1. Accessible Button Component
5interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
6 variant?: 'primary' | 'secondary' | 'danger';
7 loading?: boolean;
8 icon?: React.ReactNode;
9 children: React.ReactNode;
10}
11
12const Button: React.FC<ButtonProps> = ({
13 variant = 'primary',
14 loading = false,
15 disabled,
16 icon,
17 children,
18 'aria-label': ariaLabel,
19 ...props
20}) => {
21 // Ensure button has accessible name
22 const accessibleName = ariaLabel || (typeof children === 'string' ? children : undefined);
23
24 return (
25 <button
26 {...props}
27 disabled={disabled || loading}
28 aria-label={accessibleName}
29 aria-busy={loading}
30 aria-disabled={disabled || loading}
31 className={`btn btn-${variant} ${loading ? 'loading' : ''}`}
32 >
33 {loading ? (
34 <>
35 <span className="spinner" aria-hidden="true" />
36 <span className="sr-only">Loading...</span>
37 {children}
38 </>
39 ) : (
40 <>
41 {icon && <span aria-hidden="true">{icon}</span>}
42 {children}
43 </>
44 )}
45 </button>
46 );
47};
48
49// 2. Accessible Form with Labels and Error Messages
50interface FormFieldProps {
51 label: string;
52 error?: string;
53 required?: boolean;
54 helpText?: string;
55 children: (props: {
56 id: string;
57 'aria-invalid': boolean;
58 'aria-describedby': string | undefined;
59 'aria-required': boolean;
60 }) => React.ReactNode;
61}
62
63const FormField: React.FC<FormFieldProps> = ({
64 label,
65 error,
66 required = false,
67 helpText,
68 children
69}) => {
70 const id = useId();
71 const errorId = `${id}-error`;
72 const helpId = `${id}-help`;
73
74 const ariaDescribedBy = [
75 error && errorId,
76 helpText && helpId
77 ].filter(Boolean).join(' ') || undefined;
78
79 return (
80 <div className="form-field">
81 <label htmlFor={id} className="form-label">
82 {label}
83 {required && <span aria-label="required" className="required">*</span>}
84 </label>
85
86 {children({
87 id,
88 'aria-invalid': !!error,
89 'aria-describedby': ariaDescribedBy,
90 'aria-required': required
91 })}
92
93 {helpText && (
94 <p id={helpId} className="help-text">
95 {helpText}
96 </p>
97 )}
98
99 {error && (
100 <p id={errorId} className="error-message" role="alert">
101 <span className="sr-only">Error:</span> {error}
102 </p>
103 )}
104 </div>
105 );
106};
107
108// 3. Accessible Navigation with Landmarks
109const Navigation: React.FC = () => {
110 const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
111
112 return (
113 <nav aria-label="Main navigation">
114 <button
115 aria-expanded={mobileMenuOpen}
116 aria-controls="mobile-menu"
117 aria-label="Toggle navigation menu"
118 onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
119 className="mobile-menu-toggle"
120 >
121 <span className="hamburger" aria-hidden="true" />
122 </button>
123
124 <ul
125 id="mobile-menu"
126 className={`nav-menu ${mobileMenuOpen ? 'open' : ''}`}
127 >
128 <li><a href="/" aria-current="page">Home</a></li>
129 <li><a href="/about">About</a></li>
130 <li>
131 <button
132 aria-haspopup="true"
133 aria-expanded="false"
134 aria-controls="products-menu"
135 >
136 Products
137 </button>
138 <ul id="products-menu" role="menu">
139 <li role="menuitem"><a href="/products/software">Software</a></li>
140 <li role="menuitem"><a href="/products/hardware">Hardware</a></li>
141 </ul>
142 </li>
143 </ul>
144 </nav>
145 );
146};
147
148// 4. Accessible Modal/Dialog
149interface ModalProps {
150 isOpen: boolean;
151 onClose: () => void;
152 title: string;
153 children: React.ReactNode;
154}
155
156const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
157 const titleId = useId();
158
159 useEffect(() => {
160 if (isOpen) {
161 // Save last focused element
162 const lastFocused = document.activeElement as HTMLElement;
163
164 // Focus first focusable element in modal
165 const modal = document.getElementById('modal');
166 const focusable = modal?.querySelector<HTMLElement>(
167 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
168 );
169 focusable?.focus();
170
171 // Trap focus within modal
172 const handleTab = (e: KeyboardEvent) => {
173 if (e.key !== 'Tab') return;
174
175 const focusableElements = modal?.querySelectorAll<HTMLElement>(
176 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
177 );
178
179 if (!focusableElements?.length) return;
180
181 const firstElement = focusableElements[0];
182 const lastElement = focusableElements[focusableElements.length - 1];
183
184 if (e.shiftKey && document.activeElement === firstElement) {
185 e.preventDefault();
186 lastElement.focus();
187 } else if (!e.shiftKey && document.activeElement === lastElement) {
188 e.preventDefault();
189 firstElement.focus();
190 }
191 };
192
193 document.addEventListener('keydown', handleTab);
194
195 return () => {
196 document.removeEventListener('keydown', handleTab);
197 lastFocused?.focus();
198 };
199 }
200 }, [isOpen]);
201
202 if (!isOpen) return null;
203
204 return (
205 <div
206 role="dialog"
207 aria-modal="true"
208 aria-labelledby={titleId}
209 id="modal"
210 className="modal-overlay"
211 onClick={(e) => {
212 if (e.target === e.currentTarget) onClose();
213 }}
214 >
215 <div className="modal-content">
216 <h2 id={titleId}>{title}</h2>
217 <button
218 onClick={onClose}
219 aria-label="Close dialog"
220 className="close-button"
221 >
222 ×
223 </button>
224 {children}
225 </div>
226 </div>
227 );
228};

Advanced Patterns

1// Advanced ARIA Patterns
2// 1. Accessible Tabs Component
3interface TabsProps {
4 tabs: Array<{ id: string; label: string; content: React.ReactNode }>;
5 defaultTab?: string;
6}
7
8const Tabs: React.FC<TabsProps> = ({ tabs, defaultTab }) => {
9 const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
10 const tablistRef = useRef<HTMLDivElement>(null);
11
12 const handleKeyDown = (e: React.KeyboardEvent, currentIndex: number) => {
13 const tabButtons = tablistRef.current?.querySelectorAll('[role="tab"]');
14 if (!tabButtons) return;
15
16 let newIndex = currentIndex;
17
18 switch (e.key) {
19 case 'ArrowRight':
20 newIndex = (currentIndex + 1) % tabs.length;
21 break;
22 case 'ArrowLeft':
23 newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
24 break;
25 case 'Home':
26 newIndex = 0;
27 break;
28 case 'End':
29 newIndex = tabs.length - 1;
30 break;
31 default:
32 return;
33 }
34
35 e.preventDefault();
36 const newTab = tabs[newIndex];
37 setActiveTab(newTab.id);
38 (tabButtons[newIndex] as HTMLElement).focus();
39 };
40
41 return (
42 <div className="tabs">
43 <div
44 ref={tablistRef}
45 role="tablist"
46 aria-label="Content tabs"
47 className="tab-list"
48 >
49 {tabs.map((tab, index) => (
50 <button
51 key={tab.id}
52 role="tab"
53 id={`tab-${tab.id}`}
54 aria-selected={activeTab === tab.id}
55 aria-controls={`panel-${tab.id}`}
56 tabIndex={activeTab === tab.id ? 0 : -1}
57 onClick={() => setActiveTab(tab.id)}
58 onKeyDown={(e) => handleKeyDown(e, index)}
59 className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
60 >
61 {tab.label}
62 </button>
63 ))}
64 </div>
65
66 {tabs.map(tab => (
67 <div
68 key={tab.id}
69 role="tabpanel"
70 id={`panel-${tab.id}`}
71 aria-labelledby={`tab-${tab.id}`}
72 hidden={activeTab !== tab.id}
73 tabIndex={0}
74 className="tab-panel"
75 >
76 {tab.content}
77 </div>
78 ))}
79 </div>
80 );
81};
82
83// 2. Live Region for Dynamic Updates
84const LiveAnnouncements: React.FC = () => {
85 const [message, setMessage] = useState('');
86
87 const announce = (text: string, priority: 'polite' | 'assertive' = 'polite') => {
88 setMessage(''); // Clear first to ensure announcement
89 setTimeout(() => setMessage(text), 100);
90 };
91
92 return (
93 <>
94 <div
95 role="status"
96 aria-live="polite"
97 aria-atomic="true"
98 className="sr-only"
99 >
100 {message}
101 </div>
102
103 <button onClick={() => announce('Item added to cart')}>
104 Add to Cart
105 </button>
106
107 <button onClick={() => announce('Error: Invalid input', 'assertive')}>
108 Submit Form
109 </button>
110 </>
111 );
112};
113
114// 3. Accessible Data Table
115interface TableData {
116 headers: string[];
117 rows: Array<Record<string, any>>;
118 caption?: string;
119}
120
121const AccessibleTable: React.FC<TableData> = ({ headers, rows, caption }) => {
122 const [sortColumn, setSortColumn] = useState<string | null>(null);
123 const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
124
125 const handleSort = (column: string) => {
126 if (sortColumn === column) {
127 setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
128 } else {
129 setSortColumn(column);
130 setSortDirection('asc');
131 }
132 };
133
134 return (
135 <table role="table" aria-label={caption}>
136 {caption && <caption>{caption}</caption>}
137 <thead>
138 <tr role="row">
139 {headers.map(header => (
140 <th
141 key={header}
142 role="columnheader"
143 aria-sort={
144 sortColumn === header
145 ? sortDirection === 'asc' ? 'ascending' : 'descending'
146 : 'none'
147 }
148 >
149 <button
150 onClick={() => handleSort(header)}
151 aria-label={`Sort by ${header} ${
152 sortColumn === header
153 ? sortDirection === 'asc' ? 'descending' : 'ascending'
154 : ''
155 }`}
156 >
157 {header}
158 <span aria-hidden="true">
159 {sortColumn === header && (
160 sortDirection === 'asc' ? ' ↑' : ' ↓'
161 )}
162 </span>
163 </button>
164 </th>
165 ))}
166 </tr>
167 </thead>
168 <tbody>
169 {rows.map((row, index) => (
170 <tr key={index} role="row">
171 {headers.map(header => (
172 <td key={header} role="cell">
173 {row[header]}
174 </td>
175 ))}
176 </tr>
177 ))}
178 </tbody>
179 </table>
180 );
181};

Keyboard Navigation

Implement comprehensive keyboard navigation for all interactive elements

Keyboard accessibility ensures that all users can navigate and interact with your application without a mouse. This is crucial for users with motor disabilities and power users.

Implementation

1// Keyboard Navigation Patterns
2import React, { useRef, useState, useEffect, KeyboardEvent } from 'react';
3
4// 1. Custom Focus Management Hook
5function useFocusManager(itemCount: number) {
6 const [focusedIndex, setFocusedIndex] = useState(-1);
7 const itemRefs = useRef<(HTMLElement | null)[]>([]);
8
9 const setItemRef = (index: number) => (el: HTMLElement | null) => {
10 itemRefs.current[index] = el;
11 };
12
13 const focusItem = (index: number) => {
14 const item = itemRefs.current[index];
15 if (item) {
16 item.focus();
17 setFocusedIndex(index);
18 }
19 };
20
21 const handleKeyDown = (e: KeyboardEvent) => {
22 switch (e.key) {
23 case 'ArrowDown':
24 e.preventDefault();
25 focusItem((focusedIndex + 1) % itemCount);
26 break;
27 case 'ArrowUp':
28 e.preventDefault();
29 focusItem((focusedIndex - 1 + itemCount) % itemCount);
30 break;
31 case 'Home':
32 e.preventDefault();
33 focusItem(0);
34 break;
35 case 'End':
36 e.preventDefault();
37 focusItem(itemCount - 1);
38 break;
39 }
40 };
41
42 return { focusedIndex, setItemRef, focusItem, handleKeyDown };
43}
44
45// 2. Accessible Dropdown Menu
46interface DropdownProps {
47 trigger: React.ReactNode;
48 items: Array<{
49 label: string;
50 onClick: () => void;
51 disabled?: boolean;
52 icon?: React.ReactNode;
53 }>;
54}
55
56const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
57 const [isOpen, setIsOpen] = useState(false);
58 const { focusedIndex, setItemRef, focusItem, handleKeyDown } = useFocusManager(items.length);
59 const triggerRef = useRef<HTMLButtonElement>(null);
60
61 const handleTriggerKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
62 switch (e.key) {
63 case 'Enter':
64 case ' ':
65 case 'ArrowDown':
66 e.preventDefault();
67 setIsOpen(true);
68 setTimeout(() => focusItem(0), 0);
69 break;
70 case 'Escape':
71 setIsOpen(false);
72 triggerRef.current?.focus();
73 break;
74 }
75 };
76
77 const handleMenuKeyDown = (e: KeyboardEvent) => {
78 if (e.key === 'Escape') {
79 e.preventDefault();
80 setIsOpen(false);
81 triggerRef.current?.focus();
82 } else if (e.key === 'Tab') {
83 setIsOpen(false);
84 } else {
85 handleKeyDown(e);
86 }
87 };
88
89 // Close on outside click
90 useEffect(() => {
91 if (!isOpen) return;
92
93 const handleClickOutside = (e: MouseEvent) => {
94 const target = e.target as HTMLElement;
95 if (!target.closest('.dropdown')) {
96 setIsOpen(false);
97 }
98 };
99
100 document.addEventListener('click', handleClickOutside);
101 return () => document.removeEventListener('click', handleClickOutside);
102 }, [isOpen]);
103
104 return (
105 <div className="dropdown">
106 <button
107 ref={triggerRef}
108 aria-haspopup="true"
109 aria-expanded={isOpen}
110 onClick={() => setIsOpen(!isOpen)}
111 onKeyDown={handleTriggerKeyDown}
112 >
113 {trigger}
114 </button>
115
116 {isOpen && (
117 <ul
118 role="menu"
119 className="dropdown-menu"
120 onKeyDown={handleMenuKeyDown}
121 >
122 {items.map((item, index) => (
123 <li key={index} role="none">
124 <button
125 ref={setItemRef(index)}
126 role="menuitem"
127 tabIndex={focusedIndex === index ? 0 : -1}
128 disabled={item.disabled}
129 onClick={() => {
130 item.onClick();
131 setIsOpen(false);
132 triggerRef.current?.focus();
133 }}
134 className="dropdown-item"
135 >
136 {item.icon && <span aria-hidden="true">{item.icon}</span>}
137 {item.label}
138 </button>
139 </li>
140 ))}
141 </ul>
142 )}
143 </div>
144 );
145};
146
147// 3. Grid Navigation (e.g., Calendar, Image Gallery)
148interface GridNavigationProps {
149 items: any[];
150 columns: number;
151 renderItem: (item: any, index: number, ref: (el: HTMLElement | null) => void) => React.ReactNode;
152}
153
154const GridNavigation: React.FC<GridNavigationProps> = ({ items, columns, renderItem }) => {
155 const [focusedIndex, setFocusedIndex] = useState(0);
156 const itemRefs = useRef<(HTMLElement | null)[]>([]);
157 const rows = Math.ceil(items.length / columns);
158
159 const setItemRef = (index: number) => (el: HTMLElement | null) => {
160 itemRefs.current[index] = el;
161 };
162
163 const focusItem = (index: number) => {
164 if (index >= 0 && index < items.length) {
165 itemRefs.current[index]?.focus();
166 setFocusedIndex(index);
167 }
168 };
169
170 const handleKeyDown = (e: KeyboardEvent) => {
171 const currentRow = Math.floor(focusedIndex / columns);
172 const currentCol = focusedIndex % columns;
173 let newIndex = focusedIndex;
174
175 switch (e.key) {
176 case 'ArrowRight':
177 newIndex = Math.min(focusedIndex + 1, items.length - 1);
178 break;
179 case 'ArrowLeft':
180 newIndex = Math.max(focusedIndex - 1, 0);
181 break;
182 case 'ArrowDown':
183 newIndex = Math.min(focusedIndex + columns, items.length - 1);
184 break;
185 case 'ArrowUp':
186 newIndex = Math.max(focusedIndex - columns, 0);
187 break;
188 case 'Home':
189 if (e.ctrlKey) {
190 newIndex = 0;
191 } else {
192 newIndex = currentRow * columns;
193 }
194 break;
195 case 'End':
196 if (e.ctrlKey) {
197 newIndex = items.length - 1;
198 } else {
199 newIndex = Math.min((currentRow + 1) * columns - 1, items.length - 1);
200 }
201 break;
202 case 'PageUp':
203 newIndex = Math.max(focusedIndex - columns * 3, 0);
204 break;
205 case 'PageDown':
206 newIndex = Math.min(focusedIndex + columns * 3, items.length - 1);
207 break;
208 default:
209 return;
210 }
211
212 e.preventDefault();
213 focusItem(newIndex);
214 };
215
216 return (
217 <div
218 role="grid"
219 aria-label="Grid navigation"
220 onKeyDown={handleKeyDown}
221 className="grid-navigation"
222 >
223 {items.map((item, index) => (
224 <div key={index} role="gridcell">
225 {renderItem(item, index, setItemRef(index))}
226 </div>
227 ))}
228 </div>
229 );
230};
231
232// 4. Accessible Combobox/Autocomplete
233interface ComboboxProps {
234 options: string[];
235 value: string;
236 onChange: (value: string) => void;
237 placeholder?: string;
238}
239
240const Combobox: React.FC<ComboboxProps> = ({ options, value, onChange, placeholder }) => {
241 const [isOpen, setIsOpen] = useState(false);
242 const [activeIndex, setActiveIndex] = useState(-1);
243 const [filteredOptions, setFilteredOptions] = useState(options);
244 const inputRef = useRef<HTMLInputElement>(null);
245 const listboxId = useId();
246
247 useEffect(() => {
248 const filtered = options.filter(option =>
249 option.toLowerCase().includes(value.toLowerCase())
250 );
251 setFilteredOptions(filtered);
252 setActiveIndex(-1);
253 }, [value, options]);
254
255 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
256 switch (e.key) {
257 case 'ArrowDown':
258 e.preventDefault();
259 setIsOpen(true);
260 setActiveIndex(prev =>
261 prev < filteredOptions.length - 1 ? prev + 1 : prev
262 );
263 break;
264 case 'ArrowUp':
265 e.preventDefault();
266 if (!isOpen) {
267 setIsOpen(true);
268 } else {
269 setActiveIndex(prev => prev > 0 ? prev - 1 : -1);
270 }
271 break;
272 case 'Enter':
273 if (activeIndex >= 0 && filteredOptions[activeIndex]) {
274 e.preventDefault();
275 onChange(filteredOptions[activeIndex]);
276 setIsOpen(false);
277 }
278 break;
279 case 'Escape':
280 setIsOpen(false);
281 setActiveIndex(-1);
282 break;
283 case 'Home':
284 if (isOpen) {
285 e.preventDefault();
286 setActiveIndex(0);
287 }
288 break;
289 case 'End':
290 if (isOpen) {
291 e.preventDefault();
292 setActiveIndex(filteredOptions.length - 1);
293 }
294 break;
295 }
296 };
297
298 return (
299 <div className="combobox">
300 <input
301 ref={inputRef}
302 type="text"
303 role="combobox"
304 aria-expanded={isOpen}
305 aria-controls={listboxId}
306 aria-activedescendant={
307 activeIndex >= 0 ? `option-${activeIndex}` : undefined
308 }
309 aria-autocomplete="list"
310 value={value}
311 onChange={(e) => {
312 onChange(e.target.value);
313 setIsOpen(true);
314 }}
315 onFocus={() => setIsOpen(true)}
316 onBlur={() => setTimeout(() => setIsOpen(false), 200)}
317 onKeyDown={handleKeyDown}
318 placeholder={placeholder}
319 className="combobox-input"
320 />
321
322 {isOpen && filteredOptions.length > 0 && (
323 <ul
324 id={listboxId}
325 role="listbox"
326 className="combobox-listbox"
327 >
328 {filteredOptions.map((option, index) => (
329 <li
330 key={option}
331 id={`option-${index}`}
332 role="option"
333 aria-selected={activeIndex === index}
334 onClick={() => {
335 onChange(option);
336 setIsOpen(false);
337 inputRef.current?.focus();
338 }}
339 className={`combobox-option ${
340 activeIndex === index ? 'active' : ''
341 }`}
342 >
343 {option}
344 </li>
345 ))}
346 </ul>
347 )}
348 </div>
349 );
350};

Screen Reader Support

Optimize your React applications for screen reader users

Screen readers convert digital text into speech or braille, enabling blind and visually impaired users to access web content. Proper implementation ensures your app is fully accessible.

Implementation

1// Screen Reader Optimization Techniques
2import React, { useState, useEffect, useRef } from 'react';
3
4// 1. Visually Hidden but Screen Reader Accessible
5const VisuallyHidden: React.FC<{ children: React.ReactNode }> = ({ children }) => (
6 <span
7 className="sr-only"
8 style={{
9 position: 'absolute',
10 width: '1px',
11 height: '1px',
12 padding: 0,
13 margin: '-1px',
14 overflow: 'hidden',
15 clip: 'rect(0,0,0,0)',
16 whiteSpace: 'nowrap',
17 border: 0
18 }}
19 >
20 {children}
21 </span>
22);
23
24// 2. Loading States with Live Regions
25interface LoadingButtonProps {
26 onClick: () => Promise<void>;
27 children: React.ReactNode;
28}
29
30const LoadingButton: React.FC<LoadingButtonProps> = ({ onClick, children }) => {
31 const [loading, setLoading] = useState(false);
32 const [message, setMessage] = useState('');
33
34 const handleClick = async () => {
35 setLoading(true);
36 setMessage('Processing your request...');
37
38 try {
39 await onClick();
40 setMessage('Request completed successfully');
41 } catch (error) {
42 setMessage('Request failed. Please try again.');
43 } finally {
44 setLoading(false);
45 }
46 };
47
48 return (
49 <>
50 <button
51 onClick={handleClick}
52 disabled={loading}
53 aria-busy={loading}
54 aria-describedby="loading-status"
55 >
56 {loading && <span className="spinner" aria-hidden="true" />}
57 {children}
58 </button>
59
60 <div
61 id="loading-status"
62 role="status"
63 aria-live="polite"
64 aria-atomic="true"
65 >
66 <VisuallyHidden>{message}</VisuallyHidden>
67 </div>
68 </>
69 );
70};
71
72// 3. Complex Data Visualization with Screen Reader Support
73interface ChartData {
74 label: string;
75 value: number;
76}
77
78const AccessibleChart: React.FC<{ data: ChartData[]; title: string }> = ({ data, title }) => {
79 const max = Math.max(...data.map(d => d.value));
80 const chartId = useId();
81 const tableId = useId();
82
83 return (
84 <figure role="img" aria-labelledby={`${chartId}-title`} aria-describedby={tableId}>
85 <figcaption id={`${chartId}-title`}>{title}</figcaption>
86
87 {/* Visual Chart */}
88 <div className="chart" aria-hidden="true">
89 {data.map((item, index) => (
90 <div key={index} className="bar-container">
91 <div
92 className="bar"
93 style={{ height: `${(item.value / max) * 100}%` }}
94 />
95 <span className="label">{item.label}</span>
96 </div>
97 ))}
98 </div>
99
100 {/* Screen Reader Table */}
101 <table id={tableId} className="sr-only">
102 <caption>{title} - Data Table</caption>
103 <thead>
104 <tr>
105 <th>Category</th>
106 <th>Value</th>
107 <th>Percentage of Maximum</th>
108 </tr>
109 </thead>
110 <tbody>
111 {data.map((item, index) => (
112 <tr key={index}>
113 <td>{item.label}</td>
114 <td>{item.value}</td>
115 <td>{Math.round((item.value / max) * 100)}%</td>
116 </tr>
117 ))}
118 </tbody>
119 </table>
120 </figure>
121 );
122};
123
124// 4. Form Validation with Screen Reader Announcements
125interface FormData {
126 email: string;
127 password: string;
128}
129
130const AccessibleForm: React.FC = () => {
131 const [formData, setFormData] = useState<FormData>({ email: '', password: '' });
132 const [errors, setErrors] = useState<Partial<FormData>>({});
133 const [announcement, setAnnouncement] = useState('');
134 const [successMessage, setSuccessMessage] = useState('');
135
136 const validateField = (name: keyof FormData, value: string) => {
137 let error = '';
138
139 if (name === 'email') {
140 if (!value) {
141 error = 'Email is required';
142 } else if (!/S+@S+.S+/.test(value)) {
143 error = 'Email address is invalid';
144 }
145 } else if (name === 'password') {
146 if (!value) {
147 error = 'Password is required';
148 } else if (value.length < 8) {
149 error = 'Password must be at least 8 characters';
150 }
151 }
152
153 setErrors(prev => ({ ...prev, [name]: error }));
154
155 // Announce error to screen reader
156 if (error) {
157 setAnnouncement(`${name} error: ${error}`);
158 } else {
159 setAnnouncement(`${name} is valid`);
160 }
161 };
162
163 const handleSubmit = (e: React.FormEvent) => {
164 e.preventDefault();
165
166 // Validate all fields
167 const newErrors: Partial<FormData> = {};
168 Object.entries(formData).forEach(([key, value]) => {
169 if (!value) {
170 newErrors[key as keyof FormData] = `${key} is required`;
171 }
172 });
173
174 if (Object.keys(newErrors).length > 0) {
175 setErrors(newErrors);
176 setAnnouncement(
177 `Form has ${Object.keys(newErrors).length} errors.
178 First error: ${Object.values(newErrors)[0]}`
179 );
180 } else {
181 setSuccessMessage('Form submitted successfully!');
182 setAnnouncement('Form submitted successfully!');
183 }
184 };
185
186 return (
187 <form onSubmit={handleSubmit} noValidate>
188 <h2>Login Form</h2>
189
190 {/* Live region for form announcements */}
191 <div role="status" aria-live="assertive" aria-atomic="true" className="sr-only">
192 {announcement}
193 </div>
194
195 {/* Success message */}
196 {successMessage && (
197 <div role="alert" className="success-message">
198 <VisuallyHidden>Success:</VisuallyHidden>
199 {successMessage}
200 </div>
201 )}
202
203 {/* Email field */}
204 <div className="form-group">
205 <label htmlFor="email">
206 Email Address
207 <span aria-label="required" className="required">*</span>
208 </label>
209 <input
210 id="email"
211 type="email"
212 name="email"
213 value={formData.email}
214 onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
215 onBlur={(e) => validateField('email', e.target.value)}
216 aria-required="true"
217 aria-invalid={!!errors.email}
218 aria-describedby={errors.email ? 'email-error' : 'email-hint'}
219 className={errors.email ? 'error' : ''}
220 />
221 <span id="email-hint" className="hint">
222 Enter your email address
223 </span>
224 {errors.email && (
225 <span id="email-error" role="alert" className="error-message">
226 <VisuallyHidden>Error:</VisuallyHidden>
227 {errors.email}
228 </span>
229 )}
230 </div>
231
232 {/* Password field */}
233 <div className="form-group">
234 <label htmlFor="password">
235 Password
236 <span aria-label="required" className="required">*</span>
237 </label>
238 <input
239 id="password"
240 type="password"
241 name="password"
242 value={formData.password}
243 onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
244 onBlur={(e) => validateField('password', e.target.value)}
245 aria-required="true"
246 aria-invalid={!!errors.password}
247 aria-describedby={errors.password ? 'password-error' : 'password-hint'}
248 className={errors.password ? 'error' : ''}
249 />
250 <span id="password-hint" className="hint">
251 Must be at least 8 characters
252 </span>
253 {errors.password && (
254 <span id="password-error" role="alert" className="error-message">
255 <VisuallyHidden>Error:</VisuallyHidden>
256 {errors.password}
257 </span>
258 )}
259 </div>
260
261 <button type="submit">
262 Login
263 </button>
264 </form>
265 );
266};
267
268// 5. Skip Links for Navigation
269const SkipLinks: React.FC = () => (
270 <div className="skip-links">
271 <a href="#main-content" className="skip-link">
272 Skip to main content
273 </a>
274 <a href="#navigation" className="skip-link">
275 Skip to navigation
276 </a>
277 <a href="#search" className="skip-link">
278 Skip to search
279 </a>
280 </div>
281);

Focus Management

Manage focus states for better keyboard and screen reader navigation

Proper focus management ensures users can navigate efficiently and understand where they are in your application. This is especially important for keyboard users and screen reader users.

Implementation

1// Focus Management Patterns
2import React, { useRef, useEffect, useState } from 'react';
3
4// 1. Focus Trap Hook
5function useFocusTrap(isActive: boolean) {
6 const containerRef = useRef<HTMLDivElement>(null);
7
8 useEffect(() => {
9 if (!isActive) return;
10
11 const container = containerRef.current;
12 if (!container) return;
13
14 // Get all focusable elements
15 const getFocusableElements = () => {
16 return container.querySelectorAll<HTMLElement>(
17 'a[href], button:not([disabled]), textarea:not([disabled]), ' +
18 'input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
19 );
20 };
21
22 // Store the element that was focused before trap
23 const previouslyFocused = document.activeElement as HTMLElement;
24
25 // Focus first focusable element
26 const focusableElements = getFocusableElements();
27 if (focusableElements.length > 0) {
28 focusableElements[0].focus();
29 }
30
31 const handleKeyDown = (e: KeyboardEvent) => {
32 if (e.key !== 'Tab') return;
33
34 const focusable = getFocusableElements();
35 if (focusable.length === 0) return;
36
37 const firstElement = focusable[0];
38 const lastElement = focusable[focusable.length - 1];
39
40 // Trap focus
41 if (e.shiftKey) {
42 if (document.activeElement === firstElement) {
43 e.preventDefault();
44 lastElement.focus();
45 }
46 } else {
47 if (document.activeElement === lastElement) {
48 e.preventDefault();
49 firstElement.focus();
50 }
51 }
52 };
53
54 document.addEventListener('keydown', handleKeyDown);
55
56 return () => {
57 document.removeEventListener('keydown', handleKeyDown);
58 // Restore focus to previously focused element
59 previouslyFocused?.focus();
60 };
61 }, [isActive]);
62
63 return containerRef;
64}
65
66// 2. Focus Restoration for Dynamic Content
67interface DynamicListProps {
68 items: Array<{ id: string; name: string }>;
69 onDelete: (id: string) => void;
70}
71
72const DynamicList: React.FC<DynamicListProps> = ({ items, onDelete }) => {
73 const [deletingId, setDeletingId] = useState<string | null>(null);
74 const listRef = useRef<HTMLUListElement>(null);
75 const focusIndexRef = useRef<number>(-1);
76
77 const handleDelete = (id: string, index: number) => {
78 setDeletingId(id);
79 focusIndexRef.current = index;
80
81 // Perform delete
82 setTimeout(() => {
83 onDelete(id);
84 setDeletingId(null);
85
86 // Focus management after delete
87 setTimeout(() => {
88 const buttons = listRef.current?.querySelectorAll<HTMLButtonElement>('button');
89 if (!buttons || buttons.length === 0) return;
90
91 // Focus next item, or previous if last item was deleted
92 const newIndex = Math.min(focusIndexRef.current, buttons.length - 1);
93 buttons[newIndex]?.focus();
94 }, 0);
95 }, 300);
96 };
97
98 return (
99 <ul ref={listRef} role="list" aria-label="Deletable items">
100 {items.map((item, index) => (
101 <li
102 key={item.id}
103 className={`list-item ${deletingId === item.id ? 'deleting' : ''}`}
104 >
105 <span>{item.name}</span>
106 <button
107 onClick={() => handleDelete(item.id, index)}
108 aria-label={`Delete ${item.name}`}
109 disabled={deletingId === item.id}
110 >
111 Delete
112 </button>
113 </li>
114 ))}
115 {items.length === 0 && (
116 <li className="empty-state">No items to display</li>
117 )}
118 </ul>
119 );
120};
121
122// 3. Route Change Focus Management
123const useFocusOnRouteChange = () => {
124 const mainContentRef = useRef<HTMLElement>(null);
125 const location = useLocation(); // Assuming react-router
126
127 useEffect(() => {
128 // Announce page change to screen readers
129 const pageTitle = document.title;
130 const announcement = document.createElement('div');
131 announcement.setAttribute('role', 'status');
132 announcement.setAttribute('aria-live', 'polite');
133 announcement.className = 'sr-only';
134 announcement.textContent = `Navigated to ${pageTitle}`;
135 document.body.appendChild(announcement);
136
137 // Focus main content
138 if (mainContentRef.current) {
139 mainContentRef.current.focus();
140 }
141
142 // Cleanup
143 setTimeout(() => {
144 document.body.removeChild(announcement);
145 }, 1000);
146 }, [location]);
147
148 return mainContentRef;
149};
150
151// 4. Skip to Content Pattern
152const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
153 const mainRef = useFocusOnRouteChange();
154 const [showSkipLink, setShowSkipLink] = useState(false);
155
156 return (
157 <>
158 {/* Skip link - visible on focus */}
159 <a
160 href="#main-content"
161 className={`skip-to-content ${showSkipLink ? 'visible' : ''}`}
162 onFocus={() => setShowSkipLink(true)}
163 onBlur={() => setShowSkipLink(false)}
164 onClick={(e) => {
165 e.preventDefault();
166 mainRef.current?.focus();
167 }}
168 >
169 Skip to main content
170 </a>
171
172 <header>
173 <nav aria-label="Main navigation">
174 {/* Navigation items */}
175 </nav>
176 </header>
177
178 <main
179 ref={mainRef}
180 id="main-content"
181 tabIndex={-1}
182 className="main-content"
183 >
184 {children}
185 </main>
186
187 <footer>
188 {/* Footer content */}
189 </footer>
190 </>
191 );
192};
193
194// 5. Focus Visible Polyfill
195const FocusVisibleManager: React.FC<{ children: React.ReactNode }> = ({ children }) => {
196 useEffect(() => {
197 let hadKeyboardEvent = false;
198 const keydownHandler = () => { hadKeyboardEvent = true; };
199 const mousedownHandler = () => { hadKeyboardEvent = false; };
200
201 const focusHandler = (e: FocusEvent) => {
202 const target = e.target as HTMLElement;
203 if (hadKeyboardEvent || target.matches(':focus-visible')) {
204 target.setAttribute('data-focus-visible', 'true');
205 }
206 };
207
208 const blurHandler = (e: FocusEvent) => {
209 const target = e.target as HTMLElement;
210 target.removeAttribute('data-focus-visible');
211 };
212
213 document.addEventListener('keydown', keydownHandler, true);
214 document.addEventListener('mousedown', mousedownHandler, true);
215 document.addEventListener('focus', focusHandler, true);
216 document.addEventListener('blur', blurHandler, true);
217
218 return () => {
219 document.removeEventListener('keydown', keydownHandler, true);
220 document.removeEventListener('mousedown', mousedownHandler, true);
221 document.removeEventListener('focus', focusHandler, true);
222 document.removeEventListener('blur', blurHandler, true);
223 };
224 }, []);
225
226 return <>{children}</>;
227};

Testing Accessibility

Tools and techniques for testing React component accessibility

Automated and manual testing ensures your React applications meet accessibility standards. Learn to use testing tools and write accessibility tests.

Implementation

1// Accessibility Testing Patterns
2import { render, screen, within } from '@testing-library/react';
3import userEvent from '@testing-library/user-event';
4import { axe, toHaveNoViolations } from 'jest-axe';
5
6// Extend Jest matchers
7expect.extend(toHaveNoViolations);
8
9// 1. Basic Accessibility Tests
10describe('Button Component Accessibility', () => {
11 it('should have no accessibility violations', async () => {
12 const { container } = render(
13 <Button onClick={() => {}}>Click me</Button>
14 );
15
16 const results = await axe(container);
17 expect(results).toHaveNoViolations();
18 });
19
20 it('should have proper ARIA attributes', () => {
21 render(
22 <Button loading disabled aria-label="Save document">
23 Save
24 </Button>
25 );
26
27 const button = screen.getByRole('button', { name: 'Save document' });
28 expect(button).toHaveAttribute('aria-busy', 'true');
29 expect(button).toHaveAttribute('aria-disabled', 'true');
30 expect(button).toBeDisabled();
31 });
32
33 it('should be keyboard accessible', async () => {
34 const handleClick = jest.fn();
35 const user = userEvent.setup();
36
37 render(<Button onClick={handleClick}>Click me</Button>);
38
39 const button = screen.getByRole('button');
40
41 // Tab to button
42 await user.tab();
43 expect(button).toHaveFocus();
44
45 // Activate with Space
46 await user.keyboard(' ');
47 expect(handleClick).toHaveBeenCalledTimes(1);
48
49 // Activate with Enter
50 await user.keyboard('{Enter}');
51 expect(handleClick).toHaveBeenCalledTimes(2);
52 });
53});
54
55// 2. Form Accessibility Tests
56describe('Form Accessibility', () => {
57 it('should have accessible form fields', async () => {
58 render(<LoginForm />);
59
60 // Check labels are properly associated
61 const emailInput = screen.getByLabelText(/email address/i);
62 expect(emailInput).toHaveAttribute('type', 'email');
63 expect(emailInput).toHaveAttribute('aria-required', 'true');
64
65 const passwordInput = screen.getByLabelText(/password/i);
66 expect(passwordInput).toHaveAttribute('type', 'password');
67 });
68
69 it('should announce errors to screen readers', async () => {
70 const user = userEvent.setup();
71 render(<LoginForm />);
72
73 const emailInput = screen.getByLabelText(/email address/i);
74 const submitButton = screen.getByRole('button', { name: /login/i });
75
76 // Submit with invalid email
77 await user.type(emailInput, 'invalid-email');
78 await user.click(submitButton);
79
80 // Check error is announced
81 const error = await screen.findByRole('alert');
82 expect(error).toHaveTextContent(/email address is invalid/i);
83
84 // Check input has error state
85 expect(emailInput).toHaveAttribute('aria-invalid', 'true');
86 expect(emailInput).toHaveAttribute('aria-describedby', expect.stringContaining('error'));
87 });
88
89 it('should manage focus correctly', async () => {
90 const user = userEvent.setup();
91 render(<LoginForm />);
92
93 // Tab through form
94 await user.tab(); // Email field
95 expect(screen.getByLabelText(/email/i)).toHaveFocus();
96
97 await user.tab(); // Password field
98 expect(screen.getByLabelText(/password/i)).toHaveFocus();
99
100 await user.tab(); // Submit button
101 expect(screen.getByRole('button', { name: /login/i })).toHaveFocus();
102 });
103});
104
105// 3. Modal/Dialog Accessibility Tests
106describe('Modal Accessibility', () => {
107 it('should trap focus within modal', async () => {
108 const user = userEvent.setup();
109 render(
110 <Modal isOpen={true} onClose={() => {}} title="Confirm Action">
111 <p>Are you sure?</p>
112 <button>Cancel</button>
113 <button>Confirm</button>
114 </Modal>
115 );
116
117 const modal = screen.getByRole('dialog');
118 const buttons = within(modal).getAllByRole('button');
119
120 // First button should have focus
121 expect(buttons[0]).toHaveFocus();
122
123 // Tab through all focusable elements
124 await user.tab();
125 expect(buttons[1]).toHaveFocus();
126
127 await user.tab();
128 expect(buttons[2]).toHaveFocus();
129
130 // Should cycle back to first button
131 await user.tab();
132 expect(buttons[0]).toHaveFocus();
133
134 // Shift+Tab should go backwards
135 await user.tab({ shift: true });
136 expect(buttons[2]).toHaveFocus();
137 });
138
139 it('should have proper ARIA attributes', () => {
140 render(
141 <Modal isOpen={true} onClose={() => {}} title="Delete Item">
142 <p>This action cannot be undone.</p>
143 </Modal>
144 );
145
146 const modal = screen.getByRole('dialog');
147 expect(modal).toHaveAttribute('aria-modal', 'true');
148 expect(modal).toHaveAttribute('aria-labelledby');
149
150 // Check title is properly connected
151 const titleId = modal.getAttribute('aria-labelledby');
152 const title = document.getElementById(titleId!);
153 expect(title).toHaveTextContent('Delete Item');
154 });
155
156 it('should close on Escape key', async () => {
157 const handleClose = jest.fn();
158 const user = userEvent.setup();
159
160 render(
161 <Modal isOpen={true} onClose={handleClose} title="Test Modal">
162 Content
163 </Modal>
164 );
165
166 await user.keyboard('{Escape}');
167 expect(handleClose).toHaveBeenCalled();
168 });
169});
170
171// 4. Custom Hook for Accessibility Testing
172function useA11yTest(component: React.ReactElement) {
173 const runAccessibilityTests = async () => {
174 const { container } = render(component);
175
176 // Run axe
177 const results = await axe(container);
178
179 // Custom checks
180 const customChecks = {
181 hasSkipLinks: !!container.querySelector('.skip-link'),
182 hasLandmarks: !!container.querySelector('[role="main"]'),
183 hasHeadings: !!container.querySelector('h1, h2, h3, h4, h5, h6'),
184 imagesHaveAlt: Array.from(container.querySelectorAll('img')).every(
185 img => img.hasAttribute('alt')
186 ),
187 buttonsHaveText: Array.from(container.querySelectorAll('button')).every(
188 button => button.textContent || button.getAttribute('aria-label')
189 ),
190 formsHaveLabels: Array.from(container.querySelectorAll('input, select, textarea')).every(
191 input => {
192 const id = input.getAttribute('id');
193 return !!container.querySelector(`label[for="${id}"]`) ||
194 input.getAttribute('aria-label') ||
195 input.getAttribute('aria-labelledby');
196 }
197 )
198 };
199
200 return {
201 axeResults: results,
202 customChecks,
203 passed: !results.violations.length && Object.values(customChecks).every(Boolean)
204 };
205 };
206
207 return runAccessibilityTests;
208}
209
210// 5. Integration Test Example
211describe('Full Page Accessibility', () => {
212 it('should meet WCAG standards', async () => {
213 const { container } = render(<App />);
214
215 // Run comprehensive axe tests
216 const results = await axe(container, {
217 rules: {
218 'color-contrast': { enabled: true },
219 'valid-lang': { enabled: true },
220 'duplicate-id': { enabled: true }
221 }
222 });
223
224 expect(results).toHaveNoViolations();
225 });
226
227 it('should be navigable by keyboard only', async () => {
228 const user = userEvent.setup();
229 render(<App />);
230
231 // Tab through main navigation
232 await user.tab();
233 expect(screen.getByRole('link', { name: /skip to content/i })).toHaveFocus();
234
235 // Activate skip link
236 await user.keyboard('{Enter}');
237 expect(screen.getByRole('main')).toHaveFocus();
238
239 // Navigate through interactive elements
240 const interactiveElements = screen.getAllByRole('button')
241 .concat(screen.getAllByRole('link'))
242 .concat(screen.getAllByRole('textbox'));
243
244 for (const element of interactiveElements) {
245 // Ensure each element can receive focus
246 element.focus();
247 expect(element).toHaveFocus();
248 }
249 });
250});

Advertisement Space - mid-a11y

Google AdSense: rectangle

React Accessibility Checklist

Component Development

  • Use semantic HTML elements
  • Add ARIA labels for icons and buttons
  • Ensure keyboard navigation works
  • Test with screen readers
  • Manage focus appropriately

Testing & Validation

  • Run axe DevTools on all pages
  • Test keyboard-only navigation
  • Check color contrast ratios
  • Verify screen reader announcements
  • Test with real users

Accessibility Tools & Resources

Testing Tools

  • • axe DevTools
  • • WAVE (WebAIM)
  • • Lighthouse
  • • Pa11y
  • • NVDA (Windows)
  • • JAWS
  • • VoiceOver (Mac/iOS)

React Libraries

  • • react-aria
  • • react-a11y
  • • reach-ui
  • • react-focus-lock
  • • react-aria-live
  • • react-accessible-accordion

Guidelines

  • • WCAG 2.1 Guidelines
  • • ARIA Authoring Practices
  • • WebAIM Resources
  • • A11y Project
  • • MDN Accessibility

Advertisement Space - bottom-a11y

Google AdSense: horizontal

Related Topics

Build Inclusive React Applications

Make your React apps accessible to everyone, regardless of their abilities.

Learn Testing Next →