React Accessibility: Best Practices Guide for WCAG-Compliant SPAs
React powers millions of modern web applications, but Single Page Applications (SPAs) introduce unique accessibility challenges that traditional websites don't face: client-side routing without page refreshes, dynamic content updates without announcements, and complex component interactions.
This guide provides comprehensive, production-ready patterns for building WCAG 2.2 AA compliant React applications with proper focus management, screen reader support, and keyboard navigation.
Why React Accessibility Matters
Legal Requirements:
- ADA Title III applies to all web applications in the US
- Section 508 required for government contractors
- European Accessibility Act (EAA) now in force since June 28, 2025
- UK Equality Act 2010 covers digital services
Business Impact:
- 1 in 4 adults in the US has a disability (CDC)
- 71% of users with disabilities leave inaccessible SPAs immediately
- Higher abandonment rates with inaccessible forms and navigation
- Legal risk: Web accessibility lawsuits increased 14% year-over-year
Common React Accessibility Violations:
- Route changes not announced to screen readers
- Focus not managed after navigation
- Dynamic content updates without
aria-live - Modal dialogs without focus trapping
- Form errors without proper association
- Icon-only buttons without labels
- Keyboard navigation broken by custom components
Setting Up an Accessible React Project
Create React App with Accessibility Linting:
# Create new React app
npx create-react-app my-accessible-app
cd my-accessible-app
# Install accessibility dependencies
npm install --save-dev eslint-plugin-jsx-a11y
# Install React Router for accessible routing
npm install react-router-dom
# Install testing library for accessibility tests
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
# Install axe-core for automated testing
npm install --save-dev @axe-core/react
Configure ESLint for Accessibility (.eslintrc.json):
{
"extends": [
"react-app",
"plugin:jsx-a11y/recommended"
],
"plugins": [
"jsx-a11y"
],
"rules": {
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/alt-text": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/html-has-lang": "error",
"jsx-a11y/img-redundant-alt": "error",
"jsx-a11y/interactive-supports-focus": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/no-noninteractive-element-interactions": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error"
}
}
Enable Runtime Accessibility Checking (src/index.js):
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
// Enable axe-core in development
if (process.env.NODE_ENV !== 'production') {
const axe = require('@axe-core/react');
axe(React, ReactDOM, 1000);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Semantic JSX and ARIA
Use Semantic HTML Elements:
// β BAD: Generic divs and spans
function Navigation() {
return (
<div className="nav">
<div className="nav-item">Home</div>
<div className="nav-item">About</div>
</div>
);
}
// β
GOOD: Semantic elements
function Navigation() {
return (
<nav aria-label="Primary navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
);
}
ARIA Landmarks:
function App() {
return (
<>
<header role="banner">
<Navigation />
</header>
<main role="main" id="main-content">
<Routes>
{/* Page content */}
</Routes>
</main>
<aside role="complementary" aria-label="Sidebar">
{/* Sidebar content */}
</aside>
<footer role="contentinfo">
{/* Footer content */}
</footer>
</>
);
}
Accessible Headings Hierarchy:
// β BAD: Skipped heading levels
function ProductPage() {
return (
<>
<h1>Product Name</h1>
<h4>Description</h4> {/* Skips h2, h3 */}
<h2>Reviews</h2> {/* Out of order */}
</>
);
}
// β
GOOD: Logical heading hierarchy
function ProductPage() {
return (
<>
<h1>Product Name</h1>
<h2>Description</h2>
<h2>Reviews</h2>
<h3>Customer Review 1</h3>
<h3>Customer Review 2</h3>
</>
);
}
Focus Management in React
Skip Link Implementation:
// components/SkipLink.jsx
function SkipLink() {
return (
<a href="#main-content" className="skip-link">
Skip to main content
</a>
);
}
// App.css
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
text-decoration: none;
z-index: 1000;
}
.skip-link:focus {
top: 0;
}
Focus Management After Route Changes:
// hooks/useFocusManagement.js
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export function useFocusManagement() {
const location = useLocation();
const mainContentRef = useRef(null);
useEffect(() => {
// Focus main content after route change
if (mainContentRef.current) {
mainContentRef.current.focus();
}
}, [location.pathname]);
return mainContentRef;
}
// App.jsx
import { useFocusManagement } from './hooks/useFocusManagement';
function App() {
const mainContentRef = useFocusManagement();
return (
<>
<SkipLink />
<Header />
<main
id="main-content"
ref={mainContentRef}
tabIndex={-1} // Allow programmatic focus
className="main-content"
>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/about" element={<About />} />
</Routes>
</main>
<Footer />
</>
);
}
Focus After Delete Action:
function TodoList() {
const [todos, setTodos] = useState([...]);
const todoRefs = useRef({});
const handleDelete = (id, index) => {
setTodos(todos.filter(todo => todo.id !== id));
// Focus next item, or previous if last, or add button if empty
setTimeout(() => {
const nextIndex = index < todos.length - 1 ? index : index - 1;
if (todos.length > 1) {
todoRefs.current[todos[nextIndex].id]?.focus();
} else {
document.getElementById('add-todo-button')?.focus();
}
}, 0);
};
return (
<ul>
{todos.map((todo, index) => (
<li key={todo.id}>
<span>{todo.text}</span>
<button
ref={el => todoRefs.current[todo.id] = el}
onClick={() => handleDelete(todo.id, index)}
aria-label={`Delete ${todo.text}`}
>
Delete
</button>
</li>
))}
</ul>
);
}
Accessible Routing with React Router
Announce Route Changes to Screen Readers:
// components/RouteAnnouncer.jsx
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer() {
const location = useLocation();
useEffect(() => {
// Get page title for announcement
const pageTitle = document.title;
// Announce to screen readers
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = `Navigated to ${pageTitle}`;
document.body.appendChild(announcement);
// Remove after screen reader processes
setTimeout(() => announcement.remove(), 1000);
}, [location.pathname]);
return null;
}
// App.jsx
import { BrowserRouter } from 'react-router-dom';
import RouteAnnouncer from './components/RouteAnnouncer';
function App() {
return (
<BrowserRouter>
<RouteAnnouncer />
{/* Rest of app */}
</BrowserRouter>
);
}
Accessible Navigation Links:
// components/Navigation.jsx
import { NavLink } from 'react-router-dom';
function Navigation() {
return (
<nav aria-label="Primary navigation">
<ul className="nav-list">
<li>
<NavLink
to="/"
className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
aria-current={({ isActive }) => isActive ? 'page' : undefined}
>
Home
</NavLink>
</li>
<li>
<NavLink
to="/products"
className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
aria-current={({ isActive }) => isActive ? 'page' : undefined}
>
Products
</NavLink>
</li>
<li>
<NavLink
to="/about"
className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
aria-current={({ isActive }) => isActive ? 'page' : undefined}
>
About
</NavLink>
</li>
</ul>
</nav>
);
}
Accessible Link with External Indicator:
function ExternalLink({ href, children }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`${children} (opens in new tab)`}
>
{children}
<span className="sr-only">(opens in new tab)</span>
<svg aria-hidden="true" className="external-icon">
{/* External link icon */}
</svg>
</a>
);
}
Form Accessibility
Accessible Form with Validation:
// components/ContactForm.jsx
import { useState } from 'react';
function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
if (!formData.message.trim()) {
newErrors.message = 'Message is required';
} else if (formData.message.length < 10) {
newErrors.message = 'Message must be at least 10 characters';
}
return newErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const newErrors = validate();
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);
setSubmitted(true);
// Reset form
setFormData({ name: '', email: '', message: '' });
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<h2>Contact Us</h2>
{/* Success message */}
{submitted && (
<div
role="status"
aria-live="polite"
className="success-message"
>
Thank you! Your message has been sent.
</div>
)}
{/* Error summary */}
{Object.keys(errors).length > 0 && (
<div
role="alert"
aria-live="assertive"
className="error-summary"
>
<h3>Please fix the following errors:</h3>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>
<a href={`#${field}`}>{error}</a>
</li>
))}
</ul>
</div>
)}
{/* Name field */}
<div className="form-field">
<label htmlFor="name">
Name <span className="required" aria-label="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
autoComplete="name"
/>
{errors.name && (
<span id="name-error" className="field-error" role="alert">
{errors.name}
</span>
)}
</div>
{/* Email field */}
<div className="form-field">
<label htmlFor="email">
Email <span className="required" aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" className="field-error" role="alert">
{errors.email}
</span>
)}
</div>
{/* Message field */}
<div className="form-field">
<label htmlFor="message">
Message <span className="required" aria-label="required">*</span>
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows="6"
required
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? 'message-error message-hint' : 'message-hint'}
/>
<span id="message-hint" className="field-hint">
Minimum 10 characters
</span>
{errors.message && (
<span id="message-error" className="field-error" role="alert">
{errors.message}
</span>
)}
</div>
<button type="submit" className="btn btn-primary">
Send Message
</button>
</form>
);
}
Accessible Select Dropdown:
function CountrySelect({ value, onChange, error }) {
const countries = [
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
{ code: 'UK', name: 'United Kingdom' },
// ... more countries
];
return (
<div className="form-field">
<label htmlFor="country">
Country <span className="required" aria-label="required">*</span>
</label>
<select
id="country"
name="country"
value={value}
onChange={onChange}
required
aria-required="true"
aria-invalid={!!error}
aria-describedby={error ? 'country-error' : undefined}
autoComplete="country"
>
<option value="">Select a country</option>
{countries.map(country => (
<option key={country.code} value={country.code}>
{country.name}
</option>
))}
</select>
{error && (
<span id="country-error" className="field-error" role="alert">
{error}
</span>
)}
</div>
);
}
Modal Dialog Accessibility
Accessible Modal Component:
// components/Modal.jsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Save current focus
previousFocusRef.current = document.activeElement;
// Focus modal
modalRef.current?.focus();
// Prevent background scrolling
document.body.style.overflow = 'hidden';
// Trap focus
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
trapFocus(e, modalRef.current);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
// Restore focus
previousFocusRef.current?.focus();
};
}
}, [isOpen, onClose]);
const trapFocus = (e, element) => {
const focusableElements = element.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [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();
}
}
};
if (!isOpen) return null;
return createPortal(
<>
{/* Backdrop */}
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal"
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="modal-close"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="modal-body">
{children}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-secondary">
Cancel
</button>
<button className="btn btn-primary">
Confirm
</button>
</div>
</div>
</>,
document.body
);
}
// Usage
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<button onClick={() => setIsModalOpen(true)}>
Open Modal
</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Confirm Action"
>
<p>Are you sure you want to proceed?</p>
</Modal>
</>
);
}
Live Regions for Dynamic Content
Accessible Search with Results Announcement:
// components/Search.jsx
import { useState, useEffect } from 'react';
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
if (!query) {
setResults([]);
return;
}
setIsLoading(true);
// Debounce search
const timeoutId = setTimeout(async () => {
const data = await fetchResults(query);
setResults(data);
setIsLoading(false);
// Announce results to screen readers
const count = data.length;
setAnnouncement(
count === 0
? 'No results found'
: `${count} result${count !== 1 ? 's' : ''} found`
);
}, 500);
return () => clearTimeout(timeoutId);
}, [query]);
return (
<div className="search">
<label htmlFor="search-input">Search</label>
<input
type="search"
id="search-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-controls="search-results"
aria-describedby="search-status"
autoComplete="off"
/>
{/* Loading indicator */}
{isLoading && (
<div
id="search-status"
role="status"
aria-live="polite"
className="sr-only"
>
Loading results...
</div>
)}
{/* Results announcement */}
{!isLoading && announcement && (
<div
id="search-status"
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
)}
{/* Results list */}
<ul id="search-results" role="list">
{results.map(result => (
<li key={result.id}>
<a href={result.url}>{result.title}</a>
</li>
))}
</ul>
</div>
);
}
Toast Notification Component:
// components/Toast.jsx
import { useEffect, useState } from 'react';
function Toast({ message, type = 'info', duration = 3000, onClose }) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
onClose?.();
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
if (!isVisible) return null;
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={`toast toast-${type}`}
>
<span>{message}</span>
<button
onClick={() => {
setIsVisible(false);
onClose?.();
}}
aria-label="Dismiss notification"
className="toast-close"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
// Usage with Toast Manager
function ToastManager() {
const [toasts, setToasts] = useState([]);
const addToast = (message, type = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
};
const removeToast = (id) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
return (
<>
<button onClick={() => addToast('Item saved successfully', 'success')}>
Save Item
</button>
<div className="toast-container" aria-live="polite" aria-atomic="false">
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</>
);
}
Accessible Component Patterns
Accordion Component:
// components/Accordion.jsx
import { useState } from 'react';
function AccordionItem({ id, title, children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="accordion-item">
<h3>
<button
id={`accordion-button-${id}`}
aria-expanded={isOpen}
aria-controls={`accordion-panel-${id}`}
onClick={() => setIsOpen(!isOpen)}
className="accordion-button"
>
{title}
<span aria-hidden="true" className="accordion-icon">
{isOpen ? 'β' : '+'}
</span>
</button>
</h3>
<div
id={`accordion-panel-${id}`}
role="region"
aria-labelledby={`accordion-button-${id}`}
hidden={!isOpen}
className="accordion-panel"
>
{children}
</div>
</div>
);
}
function Accordion({ items }) {
return (
<div className="accordion">
{items.map((item, index) => (
<AccordionItem key={index} id={index} title={item.title}>
{item.content}
</AccordionItem>
))}
</div>
);
}
Tabs Component:
// components/Tabs.jsx
import { useState, useRef, useEffect } from 'react';
function Tabs({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef([]);
const handleKeyDown = (e, index) => {
let newIndex = index;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
newIndex = index > 0 ? index - 1 : tabs.length - 1;
break;
case 'ArrowRight':
e.preventDefault();
newIndex = index < tabs.length - 1 ? index + 1 : 0;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = tabs.length - 1;
break;
default:
return;
}
setActiveTab(newIndex);
tabRefs.current[newIndex]?.focus();
};
return (
<div className="tabs">
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={index}
ref={el => tabRefs.current[index] = el}
role="tab"
id={`tab-${index}`}
aria-selected={activeTab === index}
aria-controls={`panel-${index}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={activeTab === index ? 'tab active' : 'tab'}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeTab !== index}
tabIndex={0}
className="tab-panel"
>
{tab.content}
</div>
))}
</div>
);
}
Accessible Button Component:
// components/Button.jsx
function Button({
children,
onClick,
type = 'button',
variant = 'primary',
disabled = false,
isLoading = false,
ariaLabel,
...props
}) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled || isLoading}
aria-label={ariaLabel}
aria-busy={isLoading}
className={`btn btn-${variant}`}
{...props}
>
{isLoading ? (
<>
<span className="spinner" aria-hidden="true"></span>
<span className="sr-only">Loading...</span>
</>
) : (
children
)}
</button>
);
}
// Usage
<Button onClick={handleSave} isLoading={isSaving}>
Save Changes
</Button>
<Button
onClick={handleDelete}
variant="danger"
ariaLabel="Delete item"
>
<TrashIcon aria-hidden="true" />
</Button>
Testing React Apps for Accessibility
Automated Testing with Jest and Testing Library:
// ContactForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import ContactForm from './ContactForm';
expect.extend(toHaveNoViolations);
describe('ContactForm Accessibility', () => {
test('should not have accessibility violations', async () => {
const { container } = render(<ContactForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('labels are associated with inputs', () => {
render(<ContactForm />);
const nameInput = screen.getByLabelText(/name/i);
const emailInput = screen.getByLabelText(/email/i);
const messageInput = screen.getByLabelText(/message/i);
expect(nameInput).toBeInTheDocument();
expect(emailInput).toBeInTheDocument();
expect(messageInput).toBeInTheDocument();
});
test('form errors are announced to screen readers', async () => {
const user = userEvent.setup();
render(<ContactForm />);
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.click(submitButton);
// Error summary should have role="alert"
const errorSummary = screen.getByRole('alert');
expect(errorSummary).toBeInTheDocument();
// Individual field errors should be associated
const nameInput = screen.getByLabelText(/name/i);
expect(nameInput).toHaveAttribute('aria-invalid', 'true');
expect(nameInput).toHaveAttribute('aria-describedby');
});
test('focus moves to first error on invalid submission', async () => {
const user = userEvent.setup();
render(<ContactForm />);
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.click(submitButton);
await waitFor(() => {
const nameInput = screen.getByLabelText(/name/i);
expect(nameInput).toHaveFocus();
});
});
test('success message is announced to screen readers', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Fill form
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'This is a test message');
// Submit
await user.click(screen.getByRole('button', { name: /send message/i }));
// Success message should have role="status"
await waitFor(() => {
const successMessage = screen.getByRole('status');
expect(successMessage).toHaveTextContent(/thank you/i);
});
});
});
Keyboard Navigation Testing:
// Modal.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Modal from './Modal';
describe('Modal Keyboard Navigation', () => {
test('focus is trapped within modal', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render(
<div>
<button>Outside Button</button>
<Modal isOpen={true} onClose={onClose} title="Test Modal">
<button>First Button</button>
<button>Last Button</button>
</Modal>
</div>
);
const firstButton = screen.getByRole('button', { name: /first button/i });
const lastButton = screen.getByRole('button', { name: /last button/i });
// Focus should start on modal
const modal = screen.getByRole('dialog');
expect(modal).toHaveFocus();
// Tab to first button
await user.tab();
expect(firstButton).toHaveFocus();
// Tab to last button
await user.tab();
expect(lastButton).toHaveFocus();
// Tab should wrap to first button
await user.tab();
expect(firstButton).toHaveFocus();
// Shift+Tab should go to last button
await user.tab({ shift: true });
expect(lastButton).toHaveFocus();
});
test('Escape key closes modal', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render(
<Modal isOpen={true} onClose={onClose} title="Test Modal">
<p>Modal content</p>
</Modal>
);
await user.keyboard('{Escape}');
expect(onClose).toHaveBeenCalledTimes(1);
});
});
Screen Reader Testing Checklist:
Manual Screen Reader Testing (NVDA/JAWS/VoiceOver):
β‘ All images have alt text or are marked decorative
β‘ Form labels are read with inputs
β‘ Error messages are announced
β‘ Buttons have clear purposes
β‘ Links have descriptive text (not "click here")
β‘ Headings create logical document outline
β‘ Landmarks identify page regions
β‘ Dynamic content updates are announced
β‘ Modal dialogs announced when opened
β‘ Route changes are announced
β‘ Loading states are announced
β‘ Form submission results are announced
Common React Accessibility Issues
Issue 1: Missing Alt Text on Images
// β BAD: No alt text
<img src="/product.jpg" />
// β
GOOD: Descriptive alt text
<img src="/product.jpg" alt="Blue cotton t-shirt with pocket" />
// β
GOOD: Decorative image
<img src="/decoration.svg" alt="" role="presentation" />
Issue 2: onClick on Non-Interactive Elements
// β BAD: onClick on div (not keyboard accessible)
<div onClick={handleClick}>Click me</div>
// β
GOOD: Use button
<button onClick={handleClick}>Click me</button>
// β
ALTERNATIVE: Add keyboard support to div
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
Click me
</div>
Issue 3: Icon-Only Buttons
// β BAD: No text alternative
<button onClick={handleDelete}>
<TrashIcon />
</button>
// β
GOOD: aria-label provides context
<button onClick={handleDelete} aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// β
BETTER: Visible text with icon
<button onClick={handleDelete}>
<TrashIcon aria-hidden="true" />
Delete
</button>
Issue 4: Redundant ARIA
// β BAD: Redundant role and aria-label
<button role="button" aria-label="Submit">Submit</button>
// β
GOOD: Button role is implicit
<button>Submit</button>
// β
GOOD: Only use aria-label when needed
<button aria-label="Submit form">
<PaperPlaneIcon aria-hidden="true" />
</button>
Issue 5: Not Managing Focus After Delete
// β BAD: Focus disappears after delete
function handleDelete(id) {
setItems(items.filter(item => item.id !== id));
}
// β
GOOD: Focus moves to logical next element
function handleDelete(id, index) {
setItems(items.filter(item => item.id !== id));
// Focus next item or previous if last
setTimeout(() => {
const nextItem = items[index + 1] || items[index - 1];
if (nextItem) {
document.getElementById(`item-${nextItem.id}`)?.focus();
}
}, 0);
}
Integrating AllAccessible with React
AllAccessible provides automated WCAG compliance for React applications with minimal integration effort.
Installation:
npm install @allaccessible/react
Basic Integration:
// App.jsx
import { AllAccessibleWidget } from '@allaccessible/react';
function App() {
return (
<>
<AllAccessibleWidget
siteId="your-site-id"
position="bottom-right"
/>
{/* Your app content */}
<Routes>
{/* Routes */}
</Routes>
</>
);
}
Advanced Configuration:
import { AllAccessibleWidget } from '@allaccessible/react';
function App() {
return (
<>
<AllAccessibleWidget
siteId="your-site-id"
position="bottom-right"
config={{
enableKeyboardNavigation: true,
enableScreenReaderMode: true,
enableTextAdjustments: true,
enableColorAdjustments: true,
enableFocusHighlight: true,
customColors: {
primary: '#0066cc',
secondary: '#6c757d'
}
}}
/>
<YourApp />
</>
);
}
AllAccessible Features:
- β Automatic ARIA fixes - Adds missing labels, roles, states
- β Keyboard navigation - Ensures all interactive elements keyboard accessible
- β Screen reader support - Announces dynamic content changes
- β Focus management - Enhanced visible focus indicators
- β Color adjustments - Real-time contrast fixes
- β Text sizing - User-controlled text scaling (up to 200%)
- β Alt text generation - AI-powered alt text for images
- β Compliance reporting - WCAG 2.2 AA compliance certificate
Pricing:
- $10/month per domain
- 7-day free trial
- Cancel anytime
- WCAG 2.2 AA compliance guarantee
Sign up: allaccessible.org
Conclusion
Building accessible React applications requires intentional design patterns and continuous testing, but the legal compliance and improved user experience make it essential.
Key Takeaways:
- β
Enable accessibility linting - Use
eslint-plugin-jsx-a11y - β
Use semantic HTML - Prefer
<button>over<div onClick> - β Manage focus - After route changes, modals, deletions
- β
Announce changes - Use
aria-livefor dynamic content - β Trap focus in modals - Prevent keyboard users from leaving
- β
Associate labels with inputs - Use
htmlForandid - β Test with keyboard - Complete all tasks without mouse
- β Test with screen readers - NVDA, JAWS, VoiceOver
- β Automate testing - Use jest-axe in your test suite
- β Consider AllAccessible - Automated compliance for $10/month
Quick Start:
- Today: Install
eslint-plugin-jsx-a11yand fix linting errors - This week: Add focus management to route changes
- This month: Implement comprehensive accessibility testing
- Ongoing: Test with real assistive technology users
Your users deserve an accessible experience - whether they use a mouse, keyboard, screen reader, or other assistive technology. Start implementing these patterns today.
Questions? Contact our accessibility team at support@allaccessible.org.
Last updated: December 2025 | WCAG 2.2 Compliant | Reviewed by certified accessibility specialists