Accessible React Components: ARIA Patterns, Focus Management, and Screen Reader Testing
Accessibility isn't just compliance. It's about building apps that work for everyone.
Here are the patterns that matter most in production React apps.
Semantic HTML First
// Bad — div soup, screen reader can't understand structure
<div onClick={submitForm}>
<div>Submit</div>
</div>
// Good — semantic elements communicate purpose
<button type="submit" onClick={submitForm}>
Submit
</button>
Use <button> for actions, <a> for navigation, <nav> for navigation regions,
<main> for page content, <h1>-<h6> for hierarchy.
ARIA Labels
// Icon-only button needs label
<button aria-label="Close dialog">
<XIcon aria-hidden="true" /> {/* hide decorative icon from SR */}
</button>
// Associate label with input
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// Or use aria-label directly
<input
type="search"
aria-label="Search products"
placeholder="Search..."
/>
// Describe complex elements
<div
role="status"
aria-live="polite"
aria-label="Cart total"
>
${cartTotal}
</div>
Modal Focus Management
import { useEffect, useRef } from 'react'
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (isOpen) {
// Move focus into modal
closeButtonRef.current?.focus()
}
}, [isOpen])
if (!isOpen) return null
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">{title}</h2>
{children}
<button ref={closeButtonRef} onClick={onClose}>
Close
</button>
</div>
)
}
Focus Trap
// Trap focus inside modal (keyboard users can't tab out)
function useFocusTrap(ref: React.RefObject<HTMLElement>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return
const focusable = ref.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === first) {
last.focus()
e.preventDefault()
}
} else {
if (document.activeElement === last) {
first.focus()
e.preventDefault()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [active, ref])
}
Live Regions
// Announce dynamic changes to screen readers
function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const handleSave = async () => {
setStatus('saving')
try {
await onSave()
setStatus('saved')
setTimeout(() => setStatus('idle'), 2000)
} catch {
setStatus('error')
}
}
return (
<>
<button onClick={handleSave} disabled={status === 'saving'}>
{status === 'saving' ? 'Saving...' : 'Save'}
</button>
{/* Screen readers announce this change */}
<div role="status" aria-live="polite" className="sr-only">
{status === 'saved' && 'Changes saved successfully'}
{status === 'error' && 'Failed to save changes'}
</div>
</>
)
}
Testing with axe-core
npm install -D @axe-core/react
// In development only
if (process.env.NODE_ENV !== 'production') {
const axe = await import('@axe-core/react')
axe.default(React, ReactDOM, 1000)
}
Logs accessibility violations to the browser console in development.
Tailwind Screen Reader Class
// Visually hidden but announced by screen readers
<span className="sr-only">Current page:</span>
<span>Dashboard</span>
// Skip to main content link (keyboard users)
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50"
>
Skip to main content
</a>
The AI SaaS Starter Kit ships with semantic HTML, ARIA labels, and focus management patterns built in. $99 one-time.
Top comments (0)