React Accessibility: Building Accessible React Applications
React is one of the most popular JavaScript frameworks, but its component-based architecture and client-side rendering create unique accessibility challenges. This guide covers React-specific accessibility patterns, common pitfalls, and best practices for building WCAG-compliant React applications.
React Accessibility Challenges
Client-side routing: Page changes don't announce to screen readers
Focus management: Focus lost during component updates
Dynamic content: Updates not announced to assistive technology
JSX limitations: Some HTML patterns require workarounds
ARIA in JSX: Slightly different syntax than HTML
JSX and ARIA Attributes
Standard ARIA
HTML ARIA attributes work in JSX with one exception:
// aria-label, aria-labelledby, aria-describedby work as-is
<button aria-label="Close dialog">×</button>
// aria-* attributes use camelCase in JSX? No, use lowercase
<div aria-labelledby="title"> {/* Correct */}
<div ariaLabelledby="title"> {/* Wrong */}
Important: ARIA attributes stay lowercase in JSX (unlike other React props).
data-* Attributes
<button data-toggle="modal">Open</button>
Lowercase, like ARIA.
className vs class
<div className="container"> {/* React */}
<div class="container"> {/* HTML */}
htmlFor vs for
<label htmlFor="email">Email</label> {/* React */}
<label for="email">Email</label> {/* HTML */}
##Focus Management in React
Auto-focus on Mount
import { useEffect, useRef } from 'react';
function Dialog() {
const closeButtonRef = useRef(null);
useEffect(() => {
closeButtonRef.current?.focus();
}, []);
return (
<div role="dialog">
<button ref={closeButtonRef}>Close</button>
</div>
);
}
Return Focus After Unmount
function Modal({ onClose }) {
const previouslyFocusedRef = useRef(null);
useEffect(() => {
previouslyFocusedRef.current = document.activeElement;
return () => {
previouslyFocusedRef.current?.focus();
};
}, []);
return (
<div role="dialog">
<button onClick={onClose}>Close</button>
</div>
);
}
Client-Side Routing
React Router Focus Management
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
useEffect(() => {
// Move focus to main content on route change
const mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.tabIndex = -1;
mainContent.focus();
mainContent.removeAttribute('tabindex');
}
// Or announce page change
const announcement = document.getElementById('route-announce');
if (announcement) {
announcement.textContent = `Navigated to ${document.title}`;
}
}, [location]);
return <div>...</div>;
}
Route Announcements
<div
id="route-announce"
role="status"
aria-live="polite"
aria-atomic="true"
className="visually-hidden"
/>
Dynamic Content Updates
Live Regions
function SearchResults({ results, loading }) {
return (
<>
<div role="status" aria-live="polite" aria-atomic="true">
{loading ? 'Loading...' : `${results.length} results found`}
</div>
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</>
);
}
Toast Notifications
function Toast({ message }) {
return (
<div role="alert" aria-live="assertive">
{message}
</div>
);
}
Form Accessibility
Accessible Form Component
function ContactForm() {
const [errors, setErrors] = useState({});
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email address</label>
<input
type="email"
id="email"
name="email"
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
Semantic HTML in JSX
Use semantic elements:
// Good
<button onClick={handleClick}>Click me</button>
// Bad
<div onClick={handleClick}>Click me</div>
Use fragments to avoid extra divs:
return (
<>
<h1>Title</h1>
<p>Content</p>
</>
);
React Accessibility Libraries
react-aria (Adobe)
Provides accessible component hooks:
import { useButton } from 'react-aria';
function Button(props) {
const ref = useRef();
const { buttonProps } = useButton(props, ref);
return <button {...buttonProps} ref={ref}>{props.children}</button>;
}
Reach UI
Pre-built accessible components:
import { Dialog } from '@reach/dialog';
function MyDialog() {
return (
<Dialog aria-label="My dialog">
<p>Content</p>
</Dialog>
);
}
Radix UI
Unstyled accessible components:
import * as Dialog from '@radix-ui/react-dialog';
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
ESLint Plugin
eslint-plugin-jsx-a11y
Catches accessibility issues during development:
npm install --save-dev eslint-plugin-jsx-a11y
.eslintrc:
{
"extends": ["plugin:jsx-a11y/recommended"]
}
Catches:
- Missing alt text
- Invalid ARIA
- Missing labels
- Keyboard accessibility issues
Testing React Accessibility
React Testing Library
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Button is accessible', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('Button has accessible name', () => {
render(<Button>Submit</Button>);
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
Common Mistakes
onClick on div without keyboard support:
// ❌ Bad
<div onClick={handleClick}>Click</div>
// ✅ Good
<button onClick={handleClick}>Click</button>
Missing key prop in lists:
// ❌ Bad
{items.map(item => <li>{item}</li>)}
// ✅ Good
{items.map(item => <li key={item.id}>{item}</li>)}
No focus management after route change:
// ❌ Bad - focus stays on clicked link
// ✅ Good - move focus to main content
Checklist
- [ ] Use semantic HTML elements
- [ ] Manage focus on route changes
- [ ] Announce dynamic content updates
- [ ] Use eslint-plugin-jsx-a11y
- [ ] Test with React Testing Library + axe
- [ ] Provide keyboard access to all interactive elements
- [ ] Use ARIA correctly (lowercase in JSX)
- [ ] Return focus after modal closes
- [ ] Test with screen reader (NVDA, VoiceOver)
Conclusion
React accessibility requires focus management, route announcements, and proper ARIA usage. Use semantic HTML, manage focus during updates, announce dynamic changes with live regions, and leverage libraries like react-aria or Reach UI.
ESLint plugin catches many issues during development. Test with React Testing Library, axe, and screen readers. Tools like BrowseCheck monitor React applications for ongoing WCAG compliance.
Accessible React apps benefit all users with clearer interaction patterns and better keyboard support.