React Accessibility: ARIA, Focus Management and Screen Readers (2026)
Accessibility is not an optional feature — it is a quality requirement. In React, the SPA model introduces unique challenges: dynamic content updates aren't announced to screen readers, focus disappears when modals close, and custom interactive elements lack native semantics. This guide covers the patterns that make React apps usable for everyone.
Table of Contents
Semantic HTML First
The most effective accessibility fix is using the right HTML element. Native elements come with built-in keyboard handling, ARIA roles and screen reader announcements for free:
// Bad — div soup, no semantics
<div onClick={handleClick} className="btn">Submit</div>
<div onClick={handleNav} className="link">Go to docs</div>
<div className="heading">Section Title</div>
// Good — native elements
<button type="submit" onClick={handleClick}>Submit</button>
<a href="/docs">Go to docs</a>
<h2>Section Title</h2>
// If you MUST use a div as a button (avoid this)
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleClick()}
>
Click me
</div>
ARIA Roles and Attributes
// aria-label — for elements with no visible text
<button aria-label="Close dialog">
<XIcon aria-hidden="true" /> {/* Hide decorative icon from SR */}
</button>
// aria-labelledby — link label to another element's text
<h2 id="billing-heading">Billing Information</h2>
<form aria-labelledby="billing-heading">...</form>
// aria-describedby — supplementary description
<input
id="password"
type="password"
aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 12 characters with a symbol.</p>
// aria-expanded — communicates open/closed state
<button
aria-expanded={isOpen}
aria-controls="dropdown-menu"
onClick={() => setIsOpen(!isOpen)}
>
Options
</button>
<ul id="dropdown-menu" hidden={!isOpen}>...</ul>
// aria-current — marks current item in navigation
<nav>
{navItems.map(item => (
<a
key={item.href}
href={item.href}
aria-current={pathname === item.href ? 'page' : undefined}
>
{item.label}
</a>
))}
</nav>
// aria-busy — loading state
<div aria-busy={isLoading} aria-live="polite">
{isLoading ? <Spinner /> : <DataTable data={data} />}
</div>
Focus Management
React SPAs must manage focus manually when content changes — otherwise keyboard users lose their place:
import { useRef, useEffect } from 'react'
// Move focus to heading after route change
function PageLayout({ title, children }) {
const headingRef = useRef<HTMLHeadingElement>(null)
const pathname = usePathname()
useEffect(() => {
headingRef.current?.focus()
}, [pathname]) // Focus heading on every navigation
return (
<main>
<h1 ref={headingRef} tabIndex={-1} style={{ outline: 'none' }}>
{title}
</h1>
{children}
</main>
)
}
// Restore focus when a panel closes
function useRestoreFocus(isOpen: boolean) {
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Save the element that opened the panel
triggerRef.current = document.activeElement as HTMLElement
} else {
// Restore focus when panel closes
triggerRef.current?.focus()
}
}, [isOpen])
return triggerRef
}
// Focus trap — keep focus inside a modal
function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isActive: boolean) {
useEffect(() => {
if (!isActive || !containerRef.current) return
const focusable = containerRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
first?.focus()
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last?.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first?.focus() }
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isActive, containerRef])
}
Keyboard Navigation
// Arrow-key navigation for a listbox / menu
function Menu({ items, onSelect }) {
const [activeIndex, setActiveIndex] = useState(0)
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex(i => Math.min(i + 1, items.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex(i => Math.max(i - 1, 0))
break
case 'Enter':
case ' ':
e.preventDefault()
onSelect(items[activeIndex])
break
case 'Escape':
onClose()
break
case 'Home':
e.preventDefault()
setActiveIndex(0)
break
case 'End':
e.preventDefault()
setActiveIndex(items.length - 1)
break
}
}
return (
<ul role="listbox" onKeyDown={handleKeyDown}>
{items.map((item, i) => (
<li
key={item.id}
role="option"
aria-selected={i === activeIndex}
tabIndex={i === activeIndex ? 0 : -1}
onClick={() => onSelect(item)}
>
{item.label}
</li>
))}
</ul>
)
}
Live Regions
Live regions announce dynamic content updates to screen readers without moving focus:
// Toast notifications — assertive for errors, polite for success
function ToastRegion({ toasts }) {
return (
<>
{/* Polite: SR finishes current sentence before announcing */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{toasts.filter(t => t.type === 'success').map(t => t.message).join('. ')}
</div>
{/* Assertive: interrupts SR immediately — use for errors only */}
<div role="alert" aria-live="assertive" aria-atomic="true" className="sr-only">
{toasts.filter(t => t.type === 'error').map(t => t.message).join('. ')}
</div>
</>
)
}
// Search results count
function SearchResults({ query, count }) {
return (
<>
{/* Announces result count when it changes */}
<div role="status" aria-live="polite" className="sr-only">
{count} results for "{query}"
</div>
<ResultsList />
</>
)
}
Accessible Modal Pattern
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null)
useRestoreFocus(isOpen)
useFocusTrap(modalRef, isOpen)
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
if (isOpen) document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
className="modal-overlay"
>
<div className="modal-content">
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close dialog">✕</button>
</div>
{/* Click outside to close */}
<div className="modal-backdrop" onClick={onClose} aria-hidden="true" />
</div>
)
}
// Better: use Radix UI Dialog which handles all of this for you
import * as Dialog from '@radix-ui/react-dialog'
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild><Button>Open</Button></Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="modal-overlay" />
<Dialog.Content className="modal-content">
<Dialog.Title>Edit Profile</Dialog.Title>
<Dialog.Description>Make changes to your profile here.</Dialog.Description>
<Dialog.Close asChild><Button>Save</Button></Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
Accessible Forms
// Every input needs a visible label — not just placeholder
function FormField({ id, label, error, required, ...props }) {
return (
<div>
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (required)</span>}
</label>
<input
id={id}
aria-required={required}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
{...props}
/>
{error && (
<p id={`${id}-error`} role="alert" className="error-text">
{error}
</p>
)}
</div>
)
}
Testing with axe-core
npm install --save-dev @axe-core/react axe-core jest-axe
// In development — log violations to console
import React from 'react'
if (process.env.NODE_ENV !== 'production') {
const axe = require('@axe-core/react')
axe(React, ReactDOM, 1000)
}
// In jest tests
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
test('Button has no accessibility violations', async () => {
const { container } = render(<Button>Submit</Button>)
const results = await axe(container)
expect(results).toHaveNoViolations()
})