Build inclusive React applications from the start.
⚛️ For React developers
Accessibility in React apps
React has good accessibility defaults, but dynamic SPAs introduce unique challenges. This guide covers React-specific patterns and gotchas.
💡 The good news
React renders standard HTML. All your HTML accessibility knowledge applies! The challenge is managing focus, announcements, and dynamic content.
JSX accessibility basics
Use htmlFor, not for
// ❌ Won't work - for is a reserved word <label for="email">Email</label> // ✅ Use htmlFor <label htmlFor="email">Email</label> <input id="email" type="email" />
Use className, not class
// ❌ class is reserved <div class="card"> // ✅ Use className <div className="card">
ARIA in JSX
// camelCase for data-* and aria-* attributes
<button
aria-expanded={isOpen}
aria-controls="menu-content"
aria-label="Toggle menu"
>
Menu
</button>Boolean ARIA attributes
// ✅ Pass actual booleans - React handles conversion
<button aria-pressed={isActive}>Toggle</button>
// Renders as aria-pressed="true" or aria-pressed="false"Semantic HTML in components
Fragments preserve semantics
// ❌ Extra div breaks list semantics
function ListItems() {
return (
<div>
<li>Item 1</li>
<li>Item 2</li>
</div>
);
}
// ✅ Fragment preserves semantics
function ListItems() {
return (
<>
<li>Item 1</li>
<li>Item 2</li>
</>
);
}Heading levels in components
// ❌ Hardcoded heading level breaks hierarchy
function Card({ title, children }) {
return (
<article>
<h2>{title}</h2> {/* Always h2? */}
{children}
</article>
);
}
// ✅ Configurable heading level
function Card({ title, children, headingLevel = 2 }) {
const Heading = `h${headingLevel}`;
return (
<article>
<Heading>{title}</Heading>
{children}
</article>
);
}
// Usage
<Card title="Features" headingLevel={3} />Focus management
SPAs don't trigger page loads, so you must manage focus manually on route changes.
Focus on route change
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function App() {
const mainRef = useRef(null);
const location = useLocation();
useEffect(() => {
// Focus main content on route change
mainRef.current?.focus();
}, [location.pathname]);
return (
<>
<nav>...</nav>
<main ref={mainRef} tabIndex={-1}>
{/* Page content */}
</main>
</>
);
}Focus after actions
function DeleteButton({ itemId, onDelete }) {
const handleDelete = async () => {
await deleteItem(itemId);
onDelete(); // Parent moves focus to next item
};
return <button onClick={handleDelete}>Delete</button>;
}
function ItemList() {
const itemRefs = useRef({});
const handleDelete = (deletedIndex) => {
// Focus next item, or previous if last
const nextIndex = Math.min(deletedIndex, items.length - 2);
itemRefs.current[nextIndex]?.focus();
};
return (
<ul>
{items.map((item, i) => (
<li key={item.id} ref={el => itemRefs.current[i] = el} tabIndex={-1}>
{item.name}
<DeleteButton itemId={item.id} onDelete={() => handleDelete(i)} />
</li>
))}
</ul>
);
}Live announcements
Announce dynamic changes to screen reader users.
Live region component
function LiveAnnouncer({ message, politeness = 'polite' }) {
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}
// CSS
.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;
}Using a context for app-wide announcements
const AnnouncerContext = createContext();
export function AnnouncerProvider({ children }) {
const [message, setMessage] = useState('');
const announce = useCallback((text, politeness = 'polite') => {
setMessage(''); // Clear first to ensure re-announcement
setTimeout(() => setMessage(text), 50);
}, []);
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
<LiveAnnouncer message={message} />
</AnnouncerContext.Provider>
);
}
// Usage in any component
function SearchResults({ results }) {
const { announce } = useContext(AnnouncerContext);
useEffect(() => {
announce(`${results.length} results found`);
}, [results.length, announce]);
return /* ... */;
}Accessible modal component
import { useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousActiveElement = useRef(null);
// Store and restore focus
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement;
modalRef.current?.focus();
} else {
previousActiveElement.current?.focus();
}
}, [isOpen]);
// Trap focus
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
const focusables = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}, [onClose]);
// Prevent background scroll
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
className="modal"
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}Accessible forms
Form field component
function FormField({
label,
id,
error,
hint,
required,
...inputProps
}) {
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
return (
<div className="form-field">
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (required)</span>}
</label>
{hint && <p id={hintId} className="hint">{hint}</p>}
<input
id={id}
aria-describedby={describedBy}
aria-invalid={error ? 'true' : undefined}
aria-required={required}
{...inputProps}
/>
{error && (
<p id={errorId} className="error" role="alert">
{error}
</p>
)}
</div>
);
}
// Usage
<FormField
label="Email address"
id="email"
type="email"
required
hint="We'll never share your email."
error={errors.email}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>Useful accessibility hooks
useFocusTrap
function useFocusTrap(ref, isActive) {
useEffect(() => {
if (!isActive || !ref.current) return;
const element = ref.current;
const focusables = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
const handleTab = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
element.addEventListener('keydown', handleTab);
return () => element.removeEventListener('keydown', handleTab);
}, [ref, isActive]);
}useReducedMotion
function useReducedMotion() {
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
setReducedMotion(query.matches);
const handler = (e) => setReducedMotion(e.matches);
query.addEventListener('change', handler);
return () => query.removeEventListener('change', handler);
}, []);
return reducedMotion;
}
// Usage
function AnimatedComponent() {
const reducedMotion = useReducedMotion();
return (
<motion.div
animate={{ x: 100 }}
transition={{ duration: reducedMotion ? 0 : 0.5 }}
/>
);
}Testing React accessibility
jest-axe setup
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('should be accessible', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Testing focus management
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('moves focus to modal when opened', async () => {
render(<App />);
await userEvent.click(screen.getByText('Open Modal'));
expect(screen.getByRole('dialog')).toHaveFocus();
});
it('returns focus when modal closes', async () => {
render(<App />);
const openButton = screen.getByText('Open Modal');
await userEvent.click(openButton);
await userEvent.click(screen.getByText('Close'));
expect(openButton).toHaveFocus();
});Accessible component libraries
Don't reinvent the wheel. These libraries are built with accessibility in mind:
| Library | Notes |
|---|---|
| React Aria | Unstyled hooks, excellent accessibility. From Adobe. |
| Radix UI | Unstyled primitives, great for design systems. |
| Headless UI | Unstyled components, works with Tailwind. |
| Chakra UI | Styled components, built on accessibility. |
| Reach UI | Accessible foundation components. |
React accessibility checklist
- Semantic HTML elements used
- All images have alt text
- Forms have proper labels and error handling
- Focus management on route changes
- Modal/dialog focus trapping works
- Dynamic content announced via live regions
- Color contrast meets WCAG AA
- Keyboard navigation works throughout
- Skip link to main content
- Page title updates on route change
- Respects prefers-reduced-motion
- eslint-plugin-jsx-a11y enabled
- jest-axe tests for components