Accessibility in React
Building accessible React applications ensures that everyone, including people with disabilities, can use your application. Learn how to implement ARIA attributes, keyboard navigation, focus management, and test for accessibility.
Why Accessibility Matters
Web accessibility (a11y) benefits everyone:
- Legal Compliance: Many countries require websites to be accessible
- Wider Audience: Over 1 billion people have disabilities
- Better UX: Accessible sites are easier to use for everyone
- SEO Benefits: Semantic HTML improves search rankings
- Future-Proof: Works better with new devices and contexts
Semantic HTML
Use appropriate HTML elements instead of generic divs:
// Bad - Using divs for everything
function BadNavigation() {
return (
<div className="nav">
<div onClick={handleHome}>Home</div>
<div onClick={handleAbout}>About</div>
<div onClick={handleContact}>Contact</div>
</div>
);
}
// Good - Using semantic HTML
function GoodNavigation() {
return (
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
);
}
// Using semantic headings
function Article() {
return (
<article>
<h1>Main Article Title</h1>
<section>
<h2>Section Title</h2>
<p>Content goes here...</p>
</section>
<section>
<h2>Another Section</h2>
<h3>Subsection</h3>
<p>More content...</p>
</section>
</article>
);
}
Note: Screen readers use semantic HTML to navigate and understand page structure. Always use the most appropriate HTML element for the job.
ARIA Attributes
ARIA (Accessible Rich Internet Applications) attributes provide additional context for assistive technologies:
import { useState } from 'react';
// Accessible button
function DeleteButton({ onDelete, itemName }) {
return (
<button
onClick={onDelete}
aria-label={`Delete ${itemName}`}
className="delete-btn"
>
×
</button>
);
}
// Accordion with ARIA
function Accordion({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="accordion">
<button
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls="accordion-content"
id="accordion-trigger"
>
{title}
</button>
<div
id="accordion-content"
role="region"
aria-labelledby="accordion-trigger"
hidden={!isOpen}
>
{children}
</div>
</div>
);
}
// Loading state with ARIA
function LoadingButton({ isLoading, onClick, children }) {
return (
<button
onClick={onClick}
disabled={isLoading}
aria-busy={isLoading}
aria-live="polite"
>
{isLoading ? (
<>
<span aria-hidden="true">⏳</span>
<span>Loading...</span>
</>
) : (
children
)}
</button>
);
}
Tip: Use aria-label for buttons with only icons. Use aria-describedby to provide additional context. Never use aria-hidden on focusable elements.
Keyboard Navigation
Ensure all interactive elements are keyboard accessible:
import { useState, useRef } from 'react';
function AccessibleTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef([]);
const tabs = ['Profile', 'Settings', 'Notifications'];
const handleKeyDown = (e, index) => {
let nextIndex;
switch (e.key) {
case 'ArrowRight':
nextIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
nextIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveTab(nextIndex);
tabRefs.current[nextIndex]?.focus();
};
return (
<div>
<div role="tablist" aria-label="Account tabs">
{tabs.map((tab, index) => (
<button
key={tab}
ref={(el) => (tabRefs.current[index] = el)}
role="tab"
aria-selected={activeTab === index}
aria-controls={`panel-${index}`}
id={`tab-${index}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeTab !== index}
>
<h2>{tab} Content</h2>
<p>Content for {tab} tab</p>
</div>
))}
</div>
);
}
Warning: Never remove focus outlines with CSS unless you provide an alternative visible focus indicator. Users need to know where they are on the page.
Focus Management
Manage focus when content changes dynamically:
import { useState, useRef, useEffect } from 'react';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Save currently focused element
previousFocusRef.current = document.activeElement;
// Focus modal
modalRef.current?.focus();
// Trap focus inside modal
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
Form Accessibility
Make forms accessible with proper labels and error handling:
import { useState } from 'react';
function AccessibleForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
terms: false,
});
const [errors, setErrors] = useState({});
const validateForm = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (!formData.terms) {
newErrors.terms = 'You must accept the terms';
}
return newErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const newErrors = validateForm();
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// Focus first error field
const firstErrorField = Object.keys(newErrors)[0];
document.getElementById(firstErrorField)?.focus();
} else {
// Submit form
console.log('Form submitted', formData);
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
required
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
Password <span aria-label="required">*</span>
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
required
/>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password}
</span>
)}
</div>
<div>
<label>
<input
id="terms"
type="checkbox"
checked={formData.terms}
onChange={(e) =>
setFormData({ ...formData, terms: e.target.checked })
}
aria-invalid={!!errors.terms}
aria-describedby={errors.terms ? 'terms-error' : undefined}
required
/>
I accept the terms and conditions
</label>
{errors.terms && (
<span id="terms-error" role="alert" className="error">
{errors.terms}
</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
Screen Reader Announcements
Use live regions to announce dynamic content changes:
import { useState, useEffect } from 'react';
function LiveRegionExample() {
const [message, setMessage] = useState('');
const [items, setItems] = useState(['Apple', 'Banana', 'Orange']);
const addItem = () => {
const newItem = `Item ${items.length + 1}`;
setItems([...items, newItem]);
setMessage(`${newItem} added to list`);
};
const removeItem = (index) => {
const removedItem = items[index];
setItems(items.filter((_, i) => i !== index));
setMessage(`${removedItem} removed from list`);
};
return (
<div>
{/* Screen reader announcement */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
<h2>Shopping List</h2>
<ul>
{items.map((item, index) => (
<li key={index}>
{item}
<button
onClick={() => removeItem(index)}
aria-label={`Remove ${item}`}
>
Remove
</button>
</li>
))}
</ul>
<button onClick={addItem}>Add Item</button>
</div>
);
}
// CSS for screen reader only content
// .sr-only {
// position: absolute;
// width: 1px;
// height: 1px;
// padding: 0;
// margin: -1px;
// overflow: hidden;
// clip: rect(0, 0, 0, 0);
// white-space: nowrap;
// border: 0;
// }
Tip: Use aria-live="polite" for non-urgent updates and aria-live="assertive" for important, time-sensitive information.
Skip Links
Add skip links to help keyboard users navigate quickly:
function Layout({ children }) {
return (
<>
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<header>
<nav>
{/* Navigation links */}
</nav>
</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer>
{/* Footer content */}
</footer>
</>
);
}
// CSS for skip link
// .skip-link {
// position: absolute;
// top: -40px;
// left: 0;
// background: #000;
// color: #fff;
// padding: 8px;
// text-decoration: none;
// z-index: 100;
// }
//
// .skip-link:focus {
// top: 0;
// }
Testing Accessibility
Use tools and manual testing to verify accessibility:
// Install testing libraries
// npm install --save-dev @testing-library/react @testing-library/jest-dom
// npm install --save-dev jest-axe
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
function Button({ children, onClick }) {
return (
<button onClick={onClick} aria-label="Submit form">
{children}
</button>
);
}
describe('Button Accessibility', () => {
test('should not have accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('should be keyboard accessible', () => {
render(<Button>Click me</Button>);
const button = screen.getByRole('button', { name: /submit form/i });
expect(button).toBeInTheDocument();
expect(button).toHaveFocus(); // If auto-focused
});
});
Accessibility Checklist
Essential Accessibility Checklist:
- ✓ Use semantic HTML elements
- ✓ Provide text alternatives for images (alt attributes)
- ✓ Ensure sufficient color contrast (4.5:1 for normal text)
- ✓ Make all functionality keyboard accessible
- ✓ Provide visible focus indicators
- ✓ Use proper heading hierarchy (h1, h2, h3...)
- ✓ Label all form inputs
- ✓ Provide error messages and instructions
- ✓ Use ARIA attributes appropriately
- ✓ Test with keyboard navigation
- ✓ Test with screen readers (NVDA, JAWS, VoiceOver)
- ✓ Run automated accessibility tests
Exercise 1: Create an accessible dropdown menu component that works with keyboard navigation (Arrow keys, Enter, Escape). Include proper ARIA attributes and focus management.
Exercise 2: Build an accessible data table with sortable columns. Include proper table markup, keyboard controls for sorting, and screen reader announcements when sort order changes.
Exercise 3: Implement an accessible image carousel with play/pause controls, keyboard navigation (previous/next), and descriptive announcements for screen readers. Test with a screen reader to ensure proper functionality.