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