React Accessibility: Building Inclusive React Applications
React powers millions of web applications, from small personal projects to enterprise platforms used by billions. Yet the very features that make React powerful — its virtual DOM, component-based architecture, and client-side routing — can introduce accessibility barriers if developers are not deliberate about inclusion. This guide covers everything you need to know to build accessible React applications that comply with WCAG guidelines and work for all users.
Why React Accessibility Matters
React's architecture introduces unique accessibility challenges that do not exist in traditional server-rendered HTML pages. Understanding these challenges is the first step toward solving them.
The Virtual DOM and Assistive Technology. React manages a virtual DOM that it reconciles with the real DOM. When React updates the page, screen readers may not detect changes unless developers explicitly signal them using ARIA live regions or focus management. A sighted user sees content appear instantly, but a screen reader user may have no idea that anything changed.
Single Page Application (SPA) Challenges. In a traditional website, navigating to a new page triggers a full page load, and the screen reader announces the new page title. In a React SPA, route changes happen entirely in JavaScript — the browser does not reload. Without intervention, screen reader users receive no feedback that navigation occurred, leaving them stranded on a page that has visually changed but audibly stayed the same.
Component-Based Architecture. React encourages splitting UI into small, reusable components. While this is great for code organization, it can lead to fragmented accessibility. A button component might be perfectly accessible in isolation, but when composed into a complex form or modal, the overall experience breaks down if focus order, labeling, and keyboard interactions are not coordinated across components.
JSX Accessibility Fundamentals
JSX looks like HTML but has several differences that directly affect accessibility. Understanding these differences prevents common mistakes.
htmlFor Instead of for. In HTML, the <label> element uses the for attribute to associate with an input. In JSX, for is a reserved JavaScript keyword, so React uses htmlFor instead.
// Incorrect — will not associate label with input
<label for="email">Email</label>
<input id="email" type="email" />
// Correct — uses htmlFor in JSX
<label htmlFor="email">Email</label>
<input id="email" type="email" />
className Instead of class. Similarly, class is reserved in JavaScript, so JSX uses className. While this does not directly affect accessibility, it is part of understanding JSX's deviations from HTML.
ARIA Attributes in JSX. ARIA attributes are one area where JSX stays identical to HTML. All aria-* props are passed through to the DOM unchanged. The role attribute also works exactly as expected.
function SearchBar() {
return (
<div role="search" aria-label="Site search">
<label htmlFor="search-input">Search</label>
<input
id="search-input"
type="search"
aria-describedby="search-hint"
placeholder="Search articles..."
/>
<p id="search-hint">
Try searching for topics like "forms" or "navigation"
</p>
</div>
);
}
Boolean ARIA Attributes. In JSX, boolean ARIA attributes should be passed as strings, not JavaScript booleans, to ensure they render correctly in the DOM.
// Correct — string values
<button aria-expanded="true">Menu</button>
<div aria-hidden="true">Decorative content</div>
// Also works — React coerces booleans to strings for aria-* props
<button aria-expanded={isOpen}>Menu</button>
<div aria-hidden={!isVisible}>Decorative content</div>
Semantic HTML in React: Avoiding Div Soup
React makes it easy to wrap everything in <div> elements, but this destroys the semantic structure that assistive technologies rely on. Every unnecessary <div> adds noise; every missing semantic element removes meaning.
// Bad — div soup with no semantic meaning
function BadArticlePage() {
return (
<div className="page">
<div className="top-bar">
<div className="logo">My Site</div>
<div className="links">
<div onClick={() => navigate('/')}>Home</div>
<div onClick={() => navigate('/about')}>About</div>
</div>
</div>
<div className="content">
<div className="title">Article Title</div>
<div className="text">Article body text...</div>
</div>
</div>
);
}
// Good — proper semantic elements
function GoodArticlePage() {
return (
<>
<header>
<a href="/">My Site</a>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Article Title</h1>
<p>Article body text...</p>
</article>
</main>
</>
);
}
React Fragments. Use React Fragments (<>...</> or <React.Fragment>) to group elements without adding extra DOM nodes. This keeps the semantic structure clean while satisfying React's requirement for a single root element.
// Bad — wrapper div breaks table structure
function TableRow({ name, email }) {
return (
<div>
<td>{name}</td>
<td>{email}</td>
</div>
);
}
// Good — Fragment preserves table semantics
function TableRow({ name, email }) {
return (
<>
<td>{name}</td>
<td>{email}</td>
</>
);
}
Focus Management in Single Page Applications
Focus management is arguably the most important accessibility concern in React SPAs. When route changes happen without a page reload, the browser does not reset focus or announce the new content. You must handle this manually.
Using useRef for Focus Management. The useRef hook gives you a direct reference to a DOM element, which you can use to programmatically move focus.
import { useEffect, useRef } from 'react';
function PageContent({ title, children }) {
const headingRef = useRef(null);
useEffect(() => {
// Move focus to the heading when the page loads
if (headingRef.current) {
headingRef.current.focus();
}
}, [title]);
return (
<main>
<h1 ref={headingRef} tabIndex={-1}>
{title}
</h1>
{children}
</main>
);
}
Note the use of tabIndex={-1} on the heading. This allows the element to receive programmatic focus without being part of the natural tab order. Without it, calling .focus() on a heading element would silently fail in most browsers.
Route Change Announcements. In addition to moving focus, announce route changes to screen readers so users know navigation occurred.
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer() {
const location = useLocation();
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
// Wait for the new page to render, then announce
const timer = setTimeout(() => {
const pageTitle = document.title;
setAnnouncement(`Navigated to ${pageTitle}`);
}, 100);
return () => clearTimeout(timer);
}, [location.pathname]);
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{announcement}
</div>
);
}
// In your App component
function App() {
return (
<Router>
<RouteAnnouncer />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Router>
);
}
React-Specific ARIA: Live Regions and Dynamic Content
React applications frequently update content dynamically — loading data from APIs, showing notifications, updating counters. Screen readers do not detect these visual changes unless you use ARIA live regions.
aria-live Regions. An aria-live region tells the screen reader to monitor an element and announce changes to its content. The value can be polite (waits for the user to finish their current task) or assertive (interrupts immediately).
function NotificationArea() {
const [notifications, setNotifications] = useState([]);
return (
<div aria-live="polite" aria-relevant="additions">
{notifications.map((note) => (
<div key={note.id} role="alert">
{note.message}
</div>
))}
</div>
);
}
// For urgent errors, use assertive
function ErrorBanner({ error }) {
return (
<div role="alert" aria-live="assertive">
{error && <p>Error: {error.message}</p>}
</div>
);
}
Important: The live region element must be present in the DOM before its content changes. If you conditionally render the container itself, the screen reader will not have registered it as a live region and will not announce the change. Always render the container, and toggle the content inside.
aria-busy for Loading States. When a region is loading new content, use aria-busy="true" to tell screen readers to wait until loading is complete before announcing changes. This prevents partial or confusing announcements.
function DataTable({ data, isLoading }) {
return (
<section aria-label="User data" aria-busy={isLoading}>
{isLoading ? (
<p>Loading data...</p>
) : (
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
{data.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
Accessible Forms in React
Forms are where accessibility most directly affects the ability to complete tasks. An inaccessible form can prevent users from signing up, making purchases, or submitting critical information.
Labels and Controlled Components. Every form input must have a programmatically associated label. In React's controlled component pattern, the value is driven by state, which works well with accessibility as long as labels are properly connected.
function RegistrationForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
});
const [errors, setErrors] = useState({});
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]: '' }));
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="reg-name">Full name</label>
<input
id="reg-name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
autoComplete="name"
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor="reg-email">Email address</label>
<input
id="reg-email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
autoComplete="email"
/>
<p id="email-hint">We will never share your email.</p>
{errors.email && (
<p id="email-error" role="alert">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="reg-password">Password</label>
<input
id="reg-password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby="password-reqs"
autoComplete="new-password"
/>
<ul id="password-reqs">
<li>At least 8 characters</li>
<li>At least one uppercase letter</li>
<li>At least one number</li>
</ul>
{errors.password && (
<p id="password-error" role="alert">
{errors.password}
</p>
)}
</div>
<button type="submit">Create account</button>
</form>
);
}
Error Handling and Validation Messages. When form validation fails, the user needs to know three things: that an error occurred, which fields have errors, and what the errors are. Here is a pattern that handles all three.
function handleSubmit(e) {
e.preventDefault();
const newErrors = validate(formData);
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// Move focus to the error summary
errorSummaryRef.current?.focus();
return;
}
// Submit form...
}
// Error summary component
function ErrorSummary({ errors, formRef }) {
const summaryRef = useRef(null);
if (Object.keys(errors).length === 0) return null;
return (
<div
ref={summaryRef}
role="alert"
tabIndex={-1}
aria-label={`${Object.keys(errors).length} errors found`}
>
<h2>Please fix the following errors:</h2>
<ul>
{Object.entries(errors).map(([field, message]) => (
<li key={field}>
<a
href={`#reg-${field}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(`reg-${field}`)?.focus();
}}
>
{message}
</a>
</li>
))}
</ul>
</div>
);
}
Grouping Related Fields. Use <fieldset> and <legend> to group related form controls. This is especially important for radio buttons and checkboxes.
function ShippingPreference() {
const [method, setMethod] = useState('standard');
return (
<fieldset>
<legend>Shipping method</legend>
<div>
<input
type="radio"
id="ship-standard"
name="shipping"
value="standard"
checked={method === 'standard'}
onChange={(e) => setMethod(e.target.value)}
/>
<label htmlFor="ship-standard">Standard (5-7 days)</label>
</div>
<div>
<input
type="radio"
id="ship-express"
name="shipping"
value="express"
checked={method === 'express'}
onChange={(e) => setMethod(e.target.value)}
/>
<label htmlFor="ship-express">Express (2-3 days)</label>
</div>
</fieldset>
);
}
Keyboard Navigation in React
All interactive elements must be operable with a keyboard alone. React's event system makes this straightforward, but it requires intentional implementation.
onKeyDown Handlers. When building custom interactive components, you must handle keyboard events. The most common keys to support are Enter, Space, Escape, and Arrow keys.
function CustomButton({ onClick, children }) {
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
};
// Better: just use a <button> element, which handles this natively.
// Only use this pattern when you truly cannot use a native element.
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-label={typeof children === 'string' ? children : undefined}
>
{children}
</div>
);
}
Arrow Key Navigation. For composite widgets like menus, tab lists, and toolbars, use the roving tabIndex pattern. Only one item in the group has tabIndex={0}; the rest have tabIndex={-1}. Arrow keys move focus between items.
function ToolBar({ items }) {
const [activeIndex, setActiveIndex] = useState(0);
const itemRefs = useRef([]);
const handleKeyDown = (e) => {
let newIndex = activeIndex;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
newIndex = (activeIndex + 1) % items.length;
break;
case 'ArrowLeft':
e.preventDefault();
newIndex = (activeIndex - 1 + items.length) % items.length;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = items.length - 1;
break;
default:
return;
}
setActiveIndex(newIndex);
itemRefs.current[newIndex]?.focus();
};
return (
<div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
tabIndex={index === activeIndex ? 0 : -1}
aria-pressed={item.active}
onClick={() => item.onToggle()}
>
{item.label}
</button>
))}
</div>
);
}
tabIndex Management. There are only three values you should use for tabIndex:
tabIndex={0}— places the element in the natural tab order, based on its position in the DOMtabIndex={-1}— removes the element from the tab order but allows programmatic focus via JavaScript- Never use positive
tabIndexvalues (1, 2, 3, etc.) — they create unpredictable tab order and are considered a WCAG failure
Accessible Component Patterns
Many common UI patterns require careful accessibility implementation. Here are patterns for some of the most frequently built React components.
Accessible Modal Dialog. Modals are one of the most challenging components to make accessible. They require focus trapping, background inertia, and proper ARIA attributes.
import { useEffect, useRef, useCallback } from 'react';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
// Store the element that triggered the modal
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement;
// Focus the modal container
modalRef.current?.focus();
}
return () => {
// Restore focus when modal closes
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}, [isOpen]);
// Trap focus inside the modal
const handleKeyDown = useCallback(
(e) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
const modal = modalRef.current;
const focusable = modal.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
},
[onClose]
);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
aria-hidden="true"
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 50,
}}
/>
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onKeyDown={handleKeyDown}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 51,
}}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close dialog">
Close
</button>
</div>
</>
);
}
Using Libraries for Complex Components. For production applications, consider using established libraries that handle complex accessibility patterns. react-modal provides an accessible modal with focus trapping and ARIA attributes out of the box. Headless UI (from the Tailwind CSS team) provides unstyled, accessible components for common patterns.
// Using react-modal
import ReactModal from 'react-modal';
// Set the app root for screen readers (hides background content)
ReactModal.setAppElement('#root');
function MyModal({ isOpen, onRequestClose }) {
return (
<ReactModal
isOpen={isOpen}
onRequestClose={onRequestClose}
contentLabel="Example modal"
shouldCloseOnOverlayClick={true}
shouldReturnFocusAfterClose={true}
>
<h2>Modal Title</h2>
<p>Modal content here.</p>
<button onClick={onRequestClose}>Close</button>
</ReactModal>
);
}
// Using Headless UI
import { Dialog } from '@headlessui/react';
function HeadlessModal({ isOpen, onClose }) {
return (
<Dialog open={isOpen} onClose={onClose}>
<div aria-hidden="true" style={{ position: 'fixed', inset: 0 }} />
<Dialog.Panel>
<Dialog.Title>Delete account</Dialog.Title>
<Dialog.Description>
This will permanently delete your account.
</Dialog.Description>
<p>Are you sure? This action cannot be undone.</p>
<button onClick={onClose}>Cancel</button>
<button onClick={handleDelete}>Delete</button>
</Dialog.Panel>
</Dialog>
);
}
Accessible Dropdown Menu. Dropdown menus require keyboard support for opening, closing, and navigating items.
function Dropdown({ label, items }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const buttonRef = useRef(null);
const menuRef = useRef(null);
const itemRefs = useRef([]);
const handleButtonKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
case 'Enter':
case ' ':
e.preventDefault();
setIsOpen(true);
setActiveIndex(0);
break;
case 'ArrowUp':
e.preventDefault();
setIsOpen(true);
setActiveIndex(items.length - 1);
break;
}
};
const handleMenuKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % items.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
break;
case 'Escape':
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'Tab':
setIsOpen(false);
break;
}
};
useEffect(() => {
if (isOpen && activeIndex >= 0) {
itemRefs.current[activeIndex]?.focus();
}
}, [isOpen, activeIndex]);
return (
<div>
<button
ref={buttonRef}
aria-haspopup="true"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleButtonKeyDown}
>
{label}
</button>
{isOpen && (
<ul
ref={menuRef}
role="menu"
onKeyDown={handleMenuKeyDown}
>
{items.map((item, index) => (
<li
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
role="menuitem"
tabIndex={-1}
onClick={() => {
item.onSelect();
setIsOpen(false);
buttonRef.current?.focus();
}}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
Accessible Tabs. Tabs use the roving tabIndex pattern for the tab list and show/hide the corresponding panels.
function Tabs({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef([]);
const handleKeyDown = (e) => {
let newIndex = activeTab;
switch (e.key) {
case 'ArrowRight':
newIndex = (activeTab + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (activeTab - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveTab(newIndex);
tabRefs.current[newIndex]?.focus();
};
return (
<div>
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => (tabRefs.current[index] = el)}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={handleKeyDown}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
react-aria and Accessibility Libraries
Adobe's react-aria library provides a collection of React hooks that implement accessible UI patterns according to WAI-ARIA specifications. Instead of providing pre-built components, it gives you hooks that you attach to your own elements, giving you full control over styling and rendering.
import { useButton } from 'react-aria';
import { useRef } from 'react';
function MyButton(props) {
const ref = useRef(null);
const { buttonProps } = useButton(props, ref);
return (
<button {...buttonProps} ref={ref}>
{props.children}
</button>
);
}
// react-aria also provides hooks for complex patterns
import { useComboBox } from 'react-aria';
import { useComboBoxState } from 'react-stately';
function AccessibleComboBox(props) {
const state = useComboBoxState(props);
const inputRef = useRef(null);
const listBoxRef = useRef(null);
const popoverRef = useRef(null);
const { inputProps, listBoxProps, labelProps } = useComboBox(
{ ...props, inputRef, listBoxRef, popoverRef },
state
);
return (
<div>
<label {...labelProps}>{props.label}</label>
<input {...inputProps} ref={inputRef} />
{state.isOpen && (
<div ref={popoverRef}>
<ul {...listBoxProps} ref={listBoxRef}>
{/* List items */}
</ul>
</div>
)}
</div>
);
}
The advantages of react-aria include comprehensive keyboard navigation, screen reader support, touch and pointer handling, and internationalization built in. It handles edge cases you might not think of, like virtual keyboard interaction on mobile devices or drag and drop accessibility.
Other notable libraries include @radix-ui/react (unstyled accessible components), Reach UI (accessible component foundations), and Ark UI (headless component library with accessibility built in).
Testing React Accessibility
Automated testing catches roughly 30-40% of accessibility issues, but it catches them consistently and early, before they reach users. Combining automated tests with manual testing gives you strong coverage.
jest-axe: Automated WCAG Testing. The jest-axe library integrates the axe-core accessibility engine into your Jest test suite. It checks rendered components against WCAG rules and reports violations.
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('RegistrationForm', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<RegistrationForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no violations when showing errors', async () => {
const { container, getByText } = render(<RegistrationForm />);
// Submit empty form to trigger errors
fireEvent.click(getByText('Create account'));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
@testing-library/react Accessibility Queries. React Testing Library encourages accessible testing patterns by providing queries that match how users and assistive technologies find elements. Prefer getByRole, getByLabelText, and getByText over getByTestId.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('Navigation', () => {
it('opens dropdown menu with keyboard', async () => {
const user = userEvent.setup();
render(<Navigation />);
// Find by role — if this fails, the component is not accessible
const menuButton = screen.getByRole('button', { name: /settings/i });
// Keyboard interaction
await user.tab();
expect(menuButton).toHaveFocus();
await user.keyboard('{Enter}');
// Check menu opened
const menu = screen.getByRole('menu');
expect(menu).toBeInTheDocument();
// Check menu items are focusable
const menuItems = screen.getAllByRole('menuitem');
expect(menuItems[0]).toHaveFocus();
});
it('associates labels with inputs', () => {
render(<LoginForm />);
// getByLabelText verifies the label-input association
const emailInput = screen.getByLabelText('Email address');
expect(emailInput).toHaveAttribute('type', 'email');
const passwordInput = screen.getByLabelText('Password');
expect(passwordInput).toHaveAttribute('type', 'password');
});
it('announces errors to screen readers', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Check that error messages have the alert role
const alerts = screen.getAllByRole('alert');
expect(alerts.length).toBeGreaterThan(0);
});
});
eslint-plugin-jsx-a11y. This ESLint plugin catches accessibility issues at development time, before you even run your code. It checks JSX for common accessibility mistakes.
// .eslintrc.js
module.exports = {
plugins: ['jsx-a11y'],
extends: ['plugin:jsx-a11y/recommended'],
rules: {
// Customize rules as needed
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['to'],
aspects: ['noHref', 'invalidHref', 'preferButton'],
},
],
'jsx-a11y/label-has-associated-control': [
'error',
{
labelComponents: ['Label'],
labelAttributes: ['label'],
controlComponents: ['Input', 'Select'],
depth: 3,
},
],
},
};
Common issues that eslint-plugin-jsx-a11y catches include images without alt text, click handlers on non-interactive elements without keyboard support, missing form labels, invalid ARIA attributes, and autofocus usage.
Next.js Accessibility Considerations
Next.js is the most popular React framework, and it introduces its own set of accessibility considerations through its built-in components and routing system.
next/image and Alt Text. The Next.js Image component requires an alt prop, which is an excellent forcing function for accessibility. Use descriptive alt text for informational images and an empty string for decorative images.
import Image from 'next/image';
// Informational image — describe what it shows
<Image
src="/team-photo.jpg"
alt="The Acme team celebrating the product launch at headquarters"
width={800}
height={400}
/>
// Decorative image — empty alt text
<Image
src="/decorative-wave.svg"
alt=""
width={1200}
height={200}
aria-hidden="true"
/>
// Image as the only content of a link — alt describes the destination
<a href="/profile">
<Image
src="/avatar.jpg"
alt="Your profile"
width={40}
height={40}
/>
</a>
next/link. The Next.js Link component renders an <a> tag with client-side navigation. Ensure link text is descriptive and avoid generic phrases like "click here."
import Link from 'next/link';
// Bad — non-descriptive link text
<p>
To read our accessibility guide, <Link href="/guide">click here</Link>.
</p>
// Good — descriptive link text
<p>
Read our <Link href="/guide">complete accessibility guide</Link>.
</p>
// Link with icon — add aria-label or visually hidden text
<Link href="/settings" aria-label="Account settings">
<SettingsIcon aria-hidden="true" />
</Link>
Page Titles with App Router Metadata. In the Next.js App Router, use the metadata export or generateMetadata function to set page titles. Unique, descriptive page titles are a WCAG Level A requirement.
// app/about/page.tsx — static metadata
export const metadata = {
title: 'About Us | Acme Corp',
description: 'Learn about our mission and team',
};
export default function AboutPage() {
return <main>...</main>;
}
// app/blog/[slug]/page.tsx — dynamic metadata
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
title: `${post.title} | Acme Blog`,
description: post.excerpt,
};
}
// app/layout.tsx — template for consistent titles
export const metadata = {
title: {
template: '%s | Acme Corp',
default: 'Acme Corp — Build Better Products',
},
};
Next.js Route Announcements. Next.js has built-in route announcement support since version 13. It automatically announces page transitions to screen readers using the page title. However, you should verify this works correctly in your application, especially with dynamic routes.
Custom Accessibility Hooks
Building custom hooks encapsulates complex accessibility logic into reusable utilities that your entire team can use consistently.
useAnnounce Hook. This hook provides a simple API for announcing messages to screen readers from anywhere in your component tree.
import { useState, useCallback, useRef, useEffect } from 'react';
function useAnnounce() {
const [message, setMessage] = useState('');
const timeoutRef = useRef(null);
const announce = useCallback((text, priority = 'polite') => {
// Clear existing message first to ensure re-announcement
setMessage('');
// Use a microtask to set the new message
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setMessage(text);
}, 100);
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const Announcer = useCallback(
() => (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{message}
</div>
),
[message]
);
return { announce, Announcer };
}
// Usage
function TodoList() {
const { announce, Announcer } = useAnnounce();
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos((prev) => [...prev, { id: Date.now(), text }]);
announce(`Added "${text}" to your todo list`);
};
const removeTodo = (id) => {
const todo = todos.find((t) => t.id === id);
setTodos((prev) => prev.filter((t) => t.id !== id));
announce(`Removed "${todo.text}" from your todo list`);
};
return (
<>
<Announcer />
{/* Todo list UI */}
</>
);
}
useFocusTrap Hook. Focus trapping is essential for modals, drawers, and other overlay components. This hook confines keyboard focus within a specified container.
import { useEffect, useRef, useCallback } from 'react';
function useFocusTrap(isActive) {
const containerRef = useRef(null);
const getFocusableElements = useCallback(() => {
if (!containerRef.current) return [];
return Array.from(
containerRef.current.querySelectorAll(
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
'input:not([disabled]), select:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
)
);
}, []);
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const firstElement = focusable[0];
const lastElement = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isActive, getFocusableElements]);
return containerRef;
}
// Usage
function Drawer({ isOpen, onClose, children }) {
const drawerRef = useFocusTrap(isOpen);
if (!isOpen) return null;
return (
<aside
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label="Sidebar navigation"
>
<button onClick={onClose} aria-label="Close sidebar">
Close
</button>
{children}
</aside>
);
}
useMediaQuery for prefers-reduced-motion. Some users experience motion sickness or discomfort from animations. The prefers-reduced-motion media query lets you respect their system preference.
import { useState, useEffect } from 'react';
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
function usePrefersReducedMotion() {
return useMediaQuery('(prefers-reduced-motion: reduce)');
}
// Usage
function AnimatedCard({ children }) {
const prefersReducedMotion = usePrefersReducedMotion();
const style = prefersReducedMotion
? { opacity: 1 } // No animation
: {
animation: 'fadeIn 0.3s ease-in-out',
opacity: 1,
};
return <div style={style}>{children}</div>;
}
// Also useful for auto-playing carousels
function Carousel({ slides }) {
const prefersReducedMotion = usePrefersReducedMotion();
const [autoPlay, setAutoPlay] = useState(!prefersReducedMotion);
// Stop auto-play if user prefers reduced motion
useEffect(() => {
if (prefersReducedMotion) {
setAutoPlay(false);
}
}, [prefersReducedMotion]);
return (
<div
role="region"
aria-label="Image carousel"
aria-roledescription="carousel"
>
{/* Carousel implementation */}
<button
onClick={() => setAutoPlay(!autoPlay)}
aria-label={autoPlay ? 'Pause carousel' : 'Play carousel'}
>
{autoPlay ? 'Pause' : 'Play'}
</button>
</div>
);
}
Common React Accessibility Mistakes
These are the mistakes that appear most frequently in React codebases. Each example shows the problem and the fix.
Mistake 1: Using onClick on non-interactive elements.
// Bad — div is not keyboard accessible
<div onClick={handleClick}>Click me</div>
// Good — use a button
<button onClick={handleClick}>Click me</button>
// If you truly need a div (rare), add all required attributes
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
Click me
</div>
Mistake 2: Missing accessible names on icon buttons.
// Bad — screen reader announces "button" with no label
<button onClick={onClose}>
<svg viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
// Good — aria-label provides the accessible name
<button onClick={onClose} aria-label="Close">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
Mistake 3: Broken conditional aria-describedby.
// Bad — points to an element that does not exist
<input aria-describedby="error-msg" />
{hasError && <p id="error-msg">{errorText}</p>}
// Good — only set the attribute when the element exists
<input aria-describedby={hasError ? 'error-msg' : undefined} />
{hasError && <p id="error-msg">{errorText}</p>}
Mistake 4: Using a link as a button.
// Bad — anchor without href used as button
<a onClick={handleSave}>Save</a>
// Good — use a button for actions
<button onClick={handleSave}>Save</button>
// Links are for navigation, buttons are for actions
<a href="/settings">Go to settings</a> {/* navigation */}
<button onClick={saveSettings}>Save settings</button> {/* action */}
Mistake 5: Not managing focus after content changes.
// Bad — user deletes an item but focus is lost
function TodoItem({ todo, onDelete }) {
return (
<li>
{todo.text}
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
}
// Good — manage focus after deletion
function TodoList() {
const [todos, setTodos] = useState(initialTodos);
const listRef = useRef(null);
const deletedRef = useRef(false);
const handleDelete = (id) => {
setTodos((prev) => prev.filter((t) => t.id !== id));
deletedRef.current = true;
};
useEffect(() => {
if (deletedRef.current) {
// Move focus to the list or the next item
listRef.current?.focus();
deletedRef.current = false;
}
});
return (
<ul ref={listRef} tabIndex={-1} aria-label="Todo list">
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button onClick={() => handleDelete(todo.id)}>
Delete {todo.text}
</button>
</li>
))}
</ul>
);
}
Mistake 6: Autofocus without purpose.
// Bad — autoFocus on page load disorients screen reader users
function HomePage() {
return (
<div>
<h1>Welcome</h1>
<input autoFocus placeholder="Search..." />
</div>
);
}
// Good — autoFocus only in contexts where it is expected
function SearchModal({ isOpen }) {
const inputRef = useRef(null);
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);
return (
<div role="dialog" aria-label="Search">
<input ref={inputRef} aria-label="Search query" />
</div>
);
}
Mistake 7: Lists without list semantics.
// Bad — visually a list but not semantically
function TagCloud({ tags }) {
return (
<div>
{tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
);
}
// Good — proper list structure
function TagCloud({ tags }) {
return (
<ul aria-label="Tags">
{tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
);
}
Putting It All Together: An Accessible React Application
Building accessible React applications is not about adding accessibility as an afterthought. It is about making accessible choices at every level: choosing semantic elements over generic divs, managing focus during navigation and content changes, providing labels and descriptions for all interactive elements, announcing dynamic changes to screen readers, and testing with both automated tools and actual assistive technology.
Start with these high-impact practices:
- Install and configure
eslint-plugin-jsx-a11yto catch issues during development - Add
jest-axetests for every component - Use React Testing Library's
getByRolequeries, which fail when accessibility is broken - Implement a route announcer for your SPA
- Use
react-ariaor Headless UI for complex interactive components - Test with a keyboard — if you cannot complete every task without a mouse, your application is not accessible
- Test with a screen reader (VoiceOver on macOS, NVDA on Windows) at least once per release
Accessibility in React is not harder than in plain HTML — it just requires awareness of where React's abstractions can hide accessibility gaps, and the discipline to address them consistently.
Er din website tilgængelig?
Scan din website gratis og få din WCAG-score på få minutter.
Scan dit site gratis