Accessibility (a11y) in React
Accessibility (abbreviated a11y — 11 letters between the "a" and "y") means building interfaces that work for everyone, including people who use screen readers, keyboard-only navigation, voice control, or other assistive technologies. React gives you full control of the DOM, which means accessibility is entirely your responsibility — the framework won't enforce it for you.
The good news: most accessibility problems are caused by a handful of common patterns, and fixing them doesn't require deep specialised knowledge. This page walks through the most impactful changes you can make to a React application.
Semantic HTML First
The single highest-leverage accessibility improvement is using the correct HTML element. Semantic elements carry built-in meaning, keyboard behaviour, and ARIA roles that screen readers understand:
// ❌ Non-semantic — screen reader announces a generic "group", no keyboard access
function BadNavigation() {
return (
<div onClick={handleNav} className="nav">
<div className="nav-item">Home</div>
<div className="nav-item">About</div>
</div>
)
}
// ✅ Semantic — screen reader announces "navigation", links are focusable
function GoodNavigation() {
return (
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
)
}Use
<button>for clickable actions,<a>for navigation — they are keyboard focusable by defaultUse
<nav>,<main>,<aside>,<header>,<footer>landmark elements — screen readers expose these for quick page navigationUse
<h1>–<h6>heading hierarchy — screen reader users navigate by headings to scan page structureUse
<ul>/<ol>for lists — screen readers announce item countsUse
<table>with<th scope="col">for tabular data, not div grids
ARIA Attributes
ARIA (Accessible Rich Internet Applications) attributes fill gaps where native HTML semantics aren't enough — custom widgets, dynamic content, and complex interactions:
// aria-label — provides a text label when visible text isn't present
<button aria-label="Close dialog">✕</button>
// aria-describedby — associates a description with an input
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 8 characters with a number.</p>
// aria-expanded — communicates open/closed state of a collapsible section
function Accordion({ title, children }: Props) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button
aria-expanded={isOpen}
aria-controls="accordion-content"
onClick={() => setIsOpen((o) => !o)}
>
{title}
</button>
<div id="accordion-content" hidden={!isOpen}>
{children}
</div>
</div>
)
}
// aria-hidden — hide decorative elements from screen readers
<span aria-hidden="true">★★★★☆</span>
<span className="sr-only">4 out of 5 stars</span>Live Regions for Dynamic Content
When content updates dynamically — toast notifications, form validation errors, live search results — screen readers won't announce the update unless you tell them to via aria-live:
// ❌ Screen reader users miss this notification
function ToastBad({ message }: { message: string }) {
return <div className="toast">{message}</div>
}
// ✅ aria-live="polite" announces after the current speech finishes
function Toast({ message }: { message: string }) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="toast"
>
{message}
</div>
)
}
// For urgent alerts (e.g. "Your session expired"), use aria-live="assertive"
function ErrorAlert({ error }: { error: string }) {
return (
<div role="alert" aria-live="assertive">
{error}
</div>
)
}Focus Management
When you open a modal dialog, focus should move inside it. When the modal closes, focus should return to the element that triggered it. Without this, keyboard users lose their place entirely:
'use client'
import { useEffect, useRef } from 'react'
interface DialogProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
triggerRef: React.RefObject<HTMLButtonElement>
}
export function Dialog({ isOpen, onClose, children, triggerRef }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isOpen) {
// Move focus into the dialog when it opens
dialogRef.current?.focus()
} else {
// Return focus to the trigger when the dialog closes
triggerRef.current?.focus()
}
}, [isOpen, triggerRef])
if (!isOpen) return null
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
ref={dialogRef}
tabIndex={-1} // makes the div focusable programmatically
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
<h2 id="dialog-title">Confirm action</h2>
{children}
<button onClick={onClose}>Cancel</button>
</div>
)
}Keyboard Navigation
All interactive elements must be reachable and operable with the keyboard alone. Custom widgets (tab panels, dropdowns, sliders) need to implement the correct keyboard patterns defined in the ARIA Authoring Practices Guide:
'use client'
import { useState, useRef } from 'react'
const TABS = ['Overview', 'Reviews', 'Specifications']
export function TabList() {
const [activeIndex, setActiveIndex] = useState(0)
const tabRefs = useRef<(HTMLButtonElement | null)[]>([])
function handleKeyDown(e: React.KeyboardEvent, index: number) {
let nextIndex = index
if (e.key === 'ArrowRight') nextIndex = (index + 1) % TABS.length
if (e.key === 'ArrowLeft') nextIndex = (index - 1 + TABS.length) % TABS.length
if (e.key === 'Home') nextIndex = 0
if (e.key === 'End') nextIndex = TABS.length - 1
if (nextIndex !== index) {
setActiveIndex(nextIndex)
tabRefs.current[nextIndex]?.focus()
}
}
return (
<div>
<div role="tablist" aria-label="Product details">
{TABS.map((tab, i) => (
<button
key={tab}
role="tab"
aria-selected={i === activeIndex}
aria-controls={`panel-${i}`}
id={`tab-${i}`}
tabIndex={i === activeIndex ? 0 : -1}
ref={(el) => { tabRefs.current[i] = el }}
onClick={() => setActiveIndex(i)}
onKeyDown={(e) => handleKeyDown(e, i)}
>
{tab}
</button>
))}
</div>
{TABS.map((tab, i) => (
<div
key={tab}
role="tabpanel"
id={`panel-${i}`}
aria-labelledby={`tab-${i}`}
hidden={i !== activeIndex}
>
Content for {tab}
</div>
))}
</div>
)
}Skip Navigation Link
Keyboard users must tab through the entire navigation on every page unless you provide a skip link that jumps to the main content:
// components/SkipNav.tsx
export function SkipNav() {
return (
<a
href="#main-content"
className="skip-nav"
>
Skip to main content
</a>
)
}
// Corresponding CSS — visually hidden until focused
// .skip-nav {
// position: absolute;
// top: -100%;
// left: 0;
// background: #000;
// color: #fff;
// padding: 8px 16px;
// z-index: 9999;
// }
// .skip-nav:focus { top: 0; }
// In your layout
export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<>
<SkipNav />
<Header />
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
)
}Form Label Association
Every form input needs an associated label. Screen readers read the label when the input receives focus. Placeholder text is not a substitute — it disappears when the user types:
// ❌ Placeholder only — screen readers may not announce it consistently <input type="email" placeholder="Email address" /> // ✅ Explicit label association via htmlFor + id <label htmlFor="email">Email address</label> <input type="email" id="email" name="email" autoComplete="email" /> // ✅ Implicit association (label wraps input) <label> Email address <input type="email" name="email" autoComplete="email" /> </label> // ✅ Visually hidden label when design requires placeholder-style UI <label htmlFor="search" className="sr-only">Search posts</label> <input type="search" id="search" placeholder="Search posts…" />
Image Alt Text
// ❌ No alt — screen reader reads the filename or nothing <img src="/hero.jpg" /> // ✅ Descriptive alt for meaningful images <img src="/hero.jpg" alt="Two developers pair programming at a standing desk" /> // ✅ Empty alt for purely decorative images (screen reader skips it) <img src="/decorative-wave.svg" alt="" role="presentation" />
Color Contrast
WCAG 2.1 AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (>18pt or >14pt bold). Never rely on color alone to convey information:
// ❌ Color alone indicates status — invisible to colorblind users
<span style={{ color: 'red' }}>Error</span>
<span style={{ color: 'green' }}>Success</span>
// ✅ Icon + color + text — three redundant signals
<span style={{ color: '#c0392b' }}>
✕ Error: invalid email address
</span>
<span style={{ color: '#27ae60' }}>
✓ Success: profile saved
</span>Testing Tools
axe-core — install
@axe-core/reactfor runtime accessibility auditing in development; errors appear in the consoleeslint-plugin-jsx-a11y — catches common a11y mistakes at write time (missing alt, incorrect role, etc.)
Lighthouse — Chrome DevTools built-in; run an Accessibility audit for a scored report
Screen readers — VoiceOver (macOS/iOS), NVDA (Windows, free), TalkBack (Android). Manual testing is irreplaceable
React Testing Library — encourages querying by accessible role and label, which naturally promotes accessible code