
React Accessibility: Complete Guide with Code Examples (2025)
React Accessibility: Complete Guide with Code Examples (2025)
React makes it easy to build dynamic interfaces—but also easy to break accessibility. Single-page applications introduce unique challenges that server-rendered sites don't have: client-side routing, dynamic content updates, and complex state management.
This guide shows you how to build accessible React applications with real code examples you can use today.
Why React Apps Break Accessibility
Common issues in SPAs:
- Focus management: Page transitions don't automatically move focus
- Screen reader announcements: Dynamic updates aren't announced
- Keyboard navigation: Complex components miss Tab key support
- ARIA overuse: Developers use ARIA incorrectly or excessively
The golden rule: Use semantic HTML first, ARIA only when necessary.
Essential React Accessibility Patterns
1. Semantic HTML Over Divs
// ❌ Not accessible <div onClick={handleClick}>Click me</div> // ✅ Accessible <button onClick={handleClick}>Click me</button> // ❌ Div soup <div className="card"> <div className="header">Title</div> <div className="body">Content</div> </div> // ✅ Semantic structure <article className="card"> <header> <h2>Title</h2> </header> <div className="body">Content</div> </article>
Why it matters: Screen readers use semantic elements to build a navigation tree. Divs provide zero context.
2. Focus Management in SPAs
When routes change, focus should move to the main content:
// App.jsx import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; function App() { const location = useLocation(); const mainRef = useRef(null); useEffect(() => { // Move focus to main content on route change mainRef.current?.focus(); }, [location.pathname]); return ( <div> <nav>{/* Navigation */}</nav> <main ref={mainRef} tabIndex={-1} className="focus:outline-none" > {/* Page content */} </main> </div> ); }
Pro tip: Use tabIndex={-1} to make non-interactive elements focusable programmatically (but not via Tab key).
3. Live Regions for Dynamic Updates
Announce changes to screen readers without moving focus:
// ToastNotification.jsx function ToastNotification({ message, type }) { return ( <div role="status" aria-live="polite" aria-atomic="true" className="toast" > {message} </div> ); } // For urgent updates (errors): <div role="alert" aria-live="assertive"> Error: Please fix the following issues </div>
aria-live values:
polite: Announce when screen reader finishes current taskassertive: Interrupt immediately (use sparingly)off: Don't announce (default)
4. Modal Dialog Pattern
import { useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; function Modal({ isOpen, onClose, title, children }) { const closeButtonRef = useRef(null); const modalRef = useRef(null); useEffect(() => { if (isOpen) { // Store previously focused element const previouslyFocused = document.activeElement; // Focus first element in modal closeButtonRef.current?.focus(); // Trap focus within 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 && document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); // Restore focus previouslyFocused?.focus(); }; } }, [isOpen, onClose]); if (!isOpen) return null; return createPortal( <div className="modal-overlay" onClick={onClose} aria-modal="true" role="dialog" aria-labelledby="modal-title" ref={modalRef} > <div className="modal-content" onClick={(e) => e.stopPropagation()}> <h2 id="modal-title">{title}</h2> <button ref={closeButtonRef} onClick={onClose} aria-label="Close dialog" > × </button> {children} </div> </div>, document.body ); }
Key features:
- Focus trap (Tab key cycles within modal)
- Escape key closes modal
- Focus restoration when closed
- ARIA roles and labels
5. Custom Dropdown Component
function Dropdown({ label, options, value, onChange }) { const [isOpen, setIsOpen] = useState(false); const buttonRef = useRef(null); const listRef = useRef(null); const handleKeyDown = (e) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setIsOpen(true); // Focus first option break; case 'ArrowUp': e.preventDefault(); // Focus last option break; case 'Escape': setIsOpen(false); buttonRef.current?.focus(); break; } }; return ( <div className="dropdown"> <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)} onKeyDown={handleKeyDown} aria-haspopup="listbox" aria-expanded={isOpen} aria-labelledby="dropdown-label" > {value || 'Select an option'} </button> {isOpen && ( <ul ref={listRef} role="listbox" aria-labelledby="dropdown-label" > {options.map((option, index) => ( <li key={option.value} role="option" aria-selected={value === option.value} onClick={() => { onChange(option.value); setIsOpen(false); buttonRef.current?.focus(); }} > {option.label} </li> ))} </ul> )} <span id="dropdown-label" className="sr-only">{label}</span> </div> ); }
Alternative: Use headless UI libraries (Radix, Headless UI, Ariakit) instead of building from scratch.
6. Form Validation with Error Announcements
function AccessibleForm() { const [errors, setErrors] = useState({}); const [formSubmitted, setFormSubmitted] = useState(false); const handleSubmit = (e) => { e.preventDefault(); const newErrors = validateForm(); if (Object.keys(newErrors).length > 0) { setErrors(newErrors); // Move focus to first error document.querySelector('[aria-invalid="true"]')?.focus(); } else { setFormSubmitted(true); } }; return ( <form onSubmit={handleSubmit} noValidate> {/* Error summary announced to screen readers */} {Object.keys(errors).length > 0 && ( <div role="alert" className="error-summary"> <h2>Please fix the following errors:</h2> <ul> {Object.entries(errors).map(([field, message]) => ( <li key={field}> <a href={`#${field}`}>{message}</a> </li> ))} </ul> </div> )} <div className="form-group"> <label htmlFor="email">Email address *</label> <input id="email" type="email" aria-required="true" aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} /> {errors.email && ( <span id="email-error" className="error-message" role="alert"> {errors.email} </span> )} </div> <button type="submit">Submit</button> {/* Success message */} {formSubmitted && ( <div role="status" aria-live="polite"> Form submitted successfully! </div> )} </form> ); }
7. Skip Links for Keyboard Users
// Layout.jsx function Layout({ children }) { return ( <> <a href="#main" className="skip-link"> Skip to main content </a> <nav>{/* Navigation */}</nav> <main id="main">{children}</main> </> ); }
/* Skip link visible only on focus */ .skip-link { position: absolute; top: -40px; left: 0; background: #000; color: #fff; padding: 8px; z-index: 100; } .skip-link:focus { top: 0; }
Testing Your React App
Automated Testing with Jest + React Testing Library
import { render, screen } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; import userEvent from '@testing-library/user-event'; expect.extend(toHaveNoViolations); test('Button is accessible', async () => { const { container } = render(<Button>Click me</Button>); const results = await axe(container); expect(results).toHaveNoViolations(); }); test('Form is keyboard navigable', async () => { render(<ContactForm />); const user = userEvent.setup(); // Tab through form await user.tab(); expect(screen.getByLabelText(/name/i)).toHaveFocus(); await user.tab(); expect(screen.getByLabelText(/email/i)).toHaveFocus(); await user.tab(); expect(screen.getByRole('button', { name: /submit/i })).toHaveFocus(); });
Manual Testing Checklist
- Navigate entire app with Tab/Shift+Tab only
- Test with screen reader (NVDA, VoiceOver)
- Disable JavaScript (test SSR fallback)
- Test at 200% browser zoom
- Use browser DevTools accessibility inspector
Common React A11y Mistakes
1. Missing Labels on Inputs
// ❌ Bad <input placeholder="Email" /> // ✅ Good <label htmlFor="email">Email</label> <input id="email" type="email" /> // ✅ Also acceptable <input type="email" aria-label="Email address" />
2. onClick on Non-Interactive Elements
// ❌ Bad <div onClick={handleDelete}>Delete</div> // ✅ Good <button onClick={handleDelete}>Delete</button> // ✅ If you must use div (not recommended) <div role="button" tabIndex={0} onClick={handleDelete} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleDelete(); } }} > Delete </div>
3. Incorrect ARIA Usage
// ❌ Redundant ARIA <button role="button" aria-label="Close">Close</button> // ✅ Semantic HTML is enough <button>Close</button> // ❌ Wrong role <div role="button"><a href="/page">Link</a></div> // ✅ Use correct element <a href="/page">Link</a>
4. Missing Alt Text
// ❌ Bad <img src="product.jpg" /> // ✅ Descriptive alt <img src="product.jpg" alt="Blue cotton t-shirt with crew neck" /> // ✅ Decorative images <img src="divider.svg" alt="" role="presentation" />
Recommended Libraries
Headless UI (unstyled, accessible components):
- Radix UI (opens in new tab) - Most comprehensive
- Headless UI (opens in new tab) - Tailwind team's solution
- React Aria (opens in new tab) - Adobe's ARIA implementation
Styled component libraries with accessibility:
- Chakra UI (opens in new tab) - Built-in WCAG compliance
- MUI (Material-UI) (opens in new tab) - Follows Material Design a11y guidelines
- Mantine (opens in new tab) - Accessibility-first philosophy
Resources
- React Accessibility Docs (opens in new tab)
- WAI-ARIA Authoring Practices (opens in new tab)
- AccessMend React Scanner - Test your React app for WCAG violations
Quick Wins
- Install ESLint plugin:
eslint-plugin-jsx-a11y - Use semantic HTML whenever possible
- Test keyboard navigation on every new component
- Add axe-core to your Jest tests
Building accessible React apps isn't harder—it just requires awareness. Start with these patterns and iterate.
Need to audit your React app? Run a free WCAG scan or view a sample report.
Ready to fix accessibility issues?
Get a free WCAG compliance report for your website in seconds.
Related Articles
Color Contrast for Accessibility: Complete Guide with Examples
Color contrast violations are the #1 most common WCAG failure. Learn the exact ratios required, how to test them, and see before/after examples that pass compliance.
Read moreNext.js Accessibility: SEO + WCAG Optimization Guide (2025)
Next.js 15 introduces new accessibility features, but also new pitfalls. Learn how to build WCAG-compliant apps with Server Components, App Router, and proper SEO optimization.
Read moreTailwind CSS Accessibility: Common Pitfalls and Fixes (2025)
Tailwind CSS makes rapid prototyping easy but can accidentally break accessibility. Learn the most common mistakes and how to build WCAG-compliant Tailwind components.
Read more