Most React accessibility guides tell you to add alt text to images and call it done. That's table stakes. The gaps that actually hurt users with disabilities — and quietly fail WCAG 2.1 AA — are in the dynamic, interactive parts that every modern React app is built on.
This covers focus management, keyboard navigation, ARIA live regions, and a real screen reader testing workflow. Code examples are TypeScript.
Why alt text isn't enough
Static content is easy. Screen readers handle it fine without any developer intervention. The problems start the moment your UI changes without a page reload — a modal opens, a route changes, a toast fires, a combobox filters. At that point, keyboard and screen reader users are on their own unless you handle it explicitly.
The four areas below are where React apps consistently fail audits.
1. Focus management after dynamic content changes
Modals
When a modal opens, focus must move into it. When it closes, focus must return to the trigger element. Neither happens automatically.
import { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
triggerRef: React.RefObject<HTMLButtonElement>;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, triggerRef, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (isOpen) {
dialogRef.current?.showModal();
// Focus moves to the first focusable element inside
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
} else {
dialogRef.current?.close();
// Return focus to the element that opened the modal
triggerRef.current?.focus();
}
}, [isOpen, triggerRef]);
return (
<dialog ref={dialogRef} onClose={onClose}>
{children}
</dialog>
);
}
Use the native <dialog> element. It handles focus trapping and Escape to close by default, and its accessibility semantics are built in. Don't build a <div role="dialog"> unless you have a specific reason — you'll spend twice the effort reproducing what <dialog> gives you for free.
Route changes
Single-page apps are invisible to screen readers on navigation unless you tell them something changed. The browser doesn't announce route transitions the way it announces full page loads.
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export function RouteAnnouncer() {
const location = useLocation();
const announcerRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (announcerRef.current) {
// Small delay lets the new page content render first
setTimeout(() => {
const pageTitle = document.title;
if (announcerRef.current) {
announcerRef.current.textContent = `Navigated to ${pageTitle}`;
}
}, 100);
}
}, [location.pathname]);
return (
<p
ref={announcerRef}
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
);
}
Mount this once at the app root. Every route change announces the new page title to screen reader users.
2. Keyboard navigation in custom components
The browser handles keyboard navigation for native elements (<button>, <a>, <input>). The moment you build a custom interactive widget — a combobox, a tab panel, a date picker — you own the keyboard contract entirely.
Tabs (ARIA pattern)
The ARIA Authoring Practices Guide defines the keyboard contract for tabs: Arrow keys move between tabs, Tab moves into the panel, Home/End jump to first/last tab.
import { useState, useRef, KeyboardEvent } from 'react';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabPanelProps {
tabs: Tab[];
}
export function TabPanel({ tabs }: TabPanelProps) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
let nextIndex = index;
switch (e.key) {
case 'ArrowRight':
nextIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
nextIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveIndex(nextIndex);
tabRefs.current[nextIndex]?.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"
aria-selected={index === activeIndex}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={e => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={index !== activeIndex}
>
{tab.content}
</div>
))}
</div>
);
}
The tabIndex={-1} on inactive tabs is critical. It means Tab lands on the active tab only — from there, Arrow keys move between tabs and Tab moves into the panel. Without this, keyboard users tab through every tab before reaching the content.
3. ARIA live regions — when to use them and when not to
Live regions announce content changes to screen readers without moving focus. They're useful for status updates (form validation, search results count, toast notifications). They're overused everywhere else.
A reusable announcer
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';
type Politeness = 'polite' | 'assertive';
interface AnnouncerContextValue {
announce: (message: string, politeness?: Politeness) => void;
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [politeMessage, setPoliteMessage] = useState('');
const [assertiveMessage, setAssertiveMessage] = useState('');
const announce = useCallback((message: string, politeness: Politeness = 'polite') => {
// Clearing first lets the same message announce twice in a row
if (politeness === 'assertive') {
setAssertiveMessage('');
setTimeout(() => setAssertiveMessage(message), 50);
} else {
setPoliteMessage('');
setTimeout(() => setPoliteMessage(message), 50);
}
}, []);
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
<div className="sr-only">
<p aria-live="polite" aria-atomic="true">{politeMessage}</p>
<p aria-live="assertive" aria-atomic="true">{assertiveMessage}</p>
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnouncer() {
const context = useContext(AnnouncerContext);
if (!context) throw new Error('useAnnouncer must be used within AnnouncerProvider');
return context;
}
Use polite for non-urgent updates (search results loaded, item added to cart). Use assertive only for errors that require immediate action — it interrupts whatever the screen reader is saying.
When NOT to use live regions:
- Don't put them on content that already gets focus (error messages next to inputs — just move focus to the error or use
aria-describedby) - Don't use them for decorative animations or loading spinners with no meaningful status
- Don't nest live regions — only the outermost one fires
4. Screen reader testing workflow
Browser DevTools axe checks catch about 30% of real accessibility issues. The rest only surface with a screen reader. Here's a minimal workflow that catches the critical gaps.
Tools:
- NVDA (free) + Chrome on Windows
- VoiceOver (built-in) + Safari on macOS/iOS
What to test manually:
- Tab through every interactive element on the page. Confirm focus order is logical and focus is always visible.
- Open every modal, drawer, and dropdown with the keyboard. Confirm focus moves in, is trapped, and returns on close.
- Navigate every custom widget (tabs, combobox, date picker) using only arrow keys and Tab. Confirm the keyboard contract matches the ARIA pattern.
- Trigger every dynamic state change (form error, toast, search results). Confirm the screen reader announces it.
- Navigate by headings (NVDA:
Hkey, VoiceOver:Control+Option+Command+H). Confirm the heading structure makes sense out of context.
Chrome DevTools shortcut: Accessibility tree panel (Elements → Accessibility tab) shows you exactly what the screen reader sees for any element. Use it before reaching for a screen reader to catch obvious issues faster.
Practical audit checklist
Run through this before every release that touches interactive UI:
- All modals use
<dialog>or have focus trap + Escape handler - Focus returns to trigger element when modal/drawer closes
- Route changes announce the new page title
- Custom widgets implement the correct ARIA keyboard pattern
-
tabIndex={-1}on all inactive items in composite widgets (tabs, listbox, menu) - Live regions use
politeby default;assertiveonly for blocking errors - No nested live regions
- Every interactive element reachable and operable by keyboard alone
- Visible focus indicator on every focusable element (not removed by
outline: nonewithout a replacement) - Heading hierarchy: one
<h1>, logical<h2>/<h3>nesting, no skipped levels - Form inputs have associated
<label>elements (not just placeholder text) - Error messages linked to inputs via
aria-describedby - Tested with NVDA+Chrome or VoiceOver+Safari at least once per release
Key takeaways
- Static content is the easy part. Dynamic UI — modals, route changes, toasts, custom widgets — is where React apps fail screen reader users.
- Use
<dialog>for modals. It handles focus trap and Escape for free. - Every custom interactive widget needs an explicit keyboard contract matching the ARIA Authoring Practices Guide pattern.
- ARIA live regions announce changes without moving focus — use
politeby default,assertiveonly for blocking errors. - A single
AnnouncerProviderat the app root handles all live region announcements cleanly. - axe DevTools catches ~30% of real issues. Manual screen reader testing with NVDA or VoiceOver is non-negotiable before release.
Originally published at https://suprabhat-dev.com/blog/accessibility-in-react-what-developers-skip/
Top comments (0)