MENU
Accessibility
A screen reader never sees your components. It reads the accessibility tree, a parallel structure the browser builds from the DOM in which every node has a role, a name and a state. Your job in React is to emit a DOM that produces a good one.Accessibility is therefore an HTML and WCAG subject, and Volume 1 covers it as such; WCAG 2.2 has been the W3C Recommendation since October 2023, with 3.0 still an early draft. None of that changes because you render with React. The failure modes do.
Figure 2.48: Where React sits in the accessibility pipeline, and the
four places it goes wrongEvery accessibility tree below was dumped from Chromium over the
DevTools protocol.2.14.1 Semantic Markup in JSX
React has no opinion about which element you render. <div> becomes a div, <button> becomes a button, and the tree is built from whatever comes out. Every role and keyboard behavior HTML gives free is a choice made in JSX, buried inside <PrimaryAction> where reviewers cannot see it.Two spellings trip people up: attributes clashing with reserved words are renamed (className, htmlFor) and DOM properties are camelCased (tabIndex, readOnly), but aria-* and data-* keep their hyphens â aria-labelledby, never ariaLabelledBy.Landmarks are the cheapest win: one <main>, a <nav> with an aria-label per block, a <header> and <footer>. Headings are harder, because a component rendering an <h2> cannot know how deeply its parent nested it. Pass the depth through context and let each heading pick its own tag.const Level = createContext(1);
const Section = ({ children }) => <section>
<Level.Provider value={useContext(Level) + 1}>{children}</Level.Provider></section>;
const Heading = ({ children }) => {
const H = 'h' + Math.min(useContext(Level), 6); // capitalized: React reads it as a tag
return <H>{children}</H>;
};
const App = () => <>
<a href="#main">Skip to content</a>
<header><Heading>Web Coding Docs</Heading></header>
<nav aria-label="Primary"><a href="/guide">Guide</a></nav>
<main id="main"><Section><Heading>Routing</Heading>
<Section><Heading>Nested routes</Heading></Section></Section></main></>;
RootWebArea "Docs"
link "Skip to content"
banner
heading "Web Coding Docs" level=1
navigation "Primary"
main
heading "Routing" level=2
heading "Nested routes" level=3
2.14.2 Labels and ARIA Attributes
Every control needs an accessible name, and the cheapest one is a real <label htmlFor>. The React-specific problem is the id: a reusable <Field> can appear five times on a page, so a hard-coded id="email" gives five duplicates and four broken labels. useId solves it â unique per component instance and identical on server and client, so it survives hydration. Never use it as a list key.ARIA fills the gaps HTML cannot express: aria-describedby attaches hint and error text without folding it into the name, and aria-invalid marks the field as failing. Both take ID references built from that same useId value. Pass undefined, not false or '', for an attribute that should not be emitted.function Field({ label, hint, error, ...rest }) {
const id = useId(), hintId = `${id}-hint`, errId = `${id}-err`;
return <p>
<label htmlFor={id}>{label}</label><br />
<input id={id} aria-invalid={error ? true : undefined}
aria-describedby={[hint && hintId, error && errId]
.filter(Boolean).join(' ') || undefined} {...rest} />
{hint && <span id={hintId}> {hint}</span>}
{error && <span id={errId} style={{ color: '#b3261e' }}> {error}</span>}
</p>;
}
2.14.3 Keyboard Navigation
Anything a pointer can do, a keyboard must do too. A native <button> is focusable, sits in the tab order, fires onClick for Enter and Space, and reports role=button. A <div onClick> does none of that: invisible to the tab order, silent to screen readers, dead to the keyboard. It is React's most common accessibility bug.tabIndex has three meaningful values: 0 joins the natural tab order, -1 is focusable by script only, and a positive number jumps ahead of everything, wrecking the order. A toolbar, menu, tab list or grid should be one tab stop, which is what -1 is for. The ARIA Authoring Practices pattern is the roving tabindex: one child holds tabIndex={0}, the rest -1, and arrow keys move state and DOM focus together â in React, an array of refs and one onKeyDown on the container.<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>
<div id="root"></div><script type="text/babel" data-type="module">
import React, { useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
const ITEMS = ['Bold', 'Italic', 'Underline'];
function Toolbar() {
const [active, setActive] = useState(0), refs = useRef([]);
function move(e) {
const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
if (d === undefined) return; // let Tab leave the toolbar
e.preventDefault();
const i = (active + d + ITEMS.length) % ITEMS.length;
setActive(i); refs.current[i].focus();
}
return <div style={{ font: '16px system-ui', padding: 8 }}>
<div role="toolbar" aria-label="Text formatting" onKeyDown={move}>
{ITEMS.map((label, i) => <button key={label} ref={el => { refs.current[i] = el; }}
tabIndex={i === active ? 0 : -1} style={{ font: 'inherit' }}>{label}</button>)}
</div>
<p>tabIndex {ITEMS.map((l, i) => `${l}=${i === active ? 0 : -1}`).join(', ')}</p>
</div>;
}
createRoot(document.getElementById('root')).render(<Toolbar />);
const press = () => document.activeElement.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
setTimeout(() => { document.querySelector('button').focus();
press(); setTimeout(press, 100); }, 600);
</script>
Figure 2.49: Browser output of Listing 2.116The screenshot is taken after two scripted
ArrowRight presses. Focus has moved to
Underline, the only item left with tabIndex 0, so the next
Tab leaves the toolbar instead of walking through it.
Add Home and End, and aria-pressed if
the buttons are toggles.Keys belonging to the page rather than to a focused element â
Escape, / to focus search â go on window inside an
effect whose cleanup removes the listener. Forget the cleanup and you
accumulate one listener per mount, which Strict Mode's double-invoked
effects expose in development.2.14.4 Focus Management
Focus lives on a DOM node, and React deletes DOM nodes for a living. Close a panel, filter a list, change route, and the focused element disappears; the browser drops focus onto <body>, so the next Tab starts again from the top of the page. Mouse users never notice; everyone else loses their place.Three moments need handling: focus into an overlay, focus back to whatever opened it, and focus onto the new heading after a route change. The first two are free if you render a native <dialog> and call showModal() in an effect: that traps Tab, wires Escape to close, inerts the page behind it, and restores focus on close. <dialog open> gives a non-modal dialog with none of it.function Rename({ open, onClose }) {
const ref = useRef(null), field = useRef(null);
useEffect(() => {
if (!open) return;
ref.current.showModal(); // traps Tab, handles Escape, inerts the page
field.current.focus(); // put focus where the user has to act
return () => ref.current?.close();
}, [open]);
return <dialog ref={ref} aria-labelledby="t" onClose={onClose}>
<h2 id="t">Rename file</h2>
<label htmlFor="n">New name</label> <input id="n" ref={field} defaultValue="notes.md" />
<button onClick={onClose}>Save</button> <button onClick={onClose}>Cancel</button>
</dialog>;
}
2.14.5 Announcing Dynamic Changes
On a traditional site every change arrives with a page load and the screen reader starts reading again. In a React application nothing loads: results filter, a row is deleted, a save succeeds, and a user not looking at the screen is told none of it. Live regions are the fix â mark a container aria-live and the browser reports its text changes without moving focus. Two roles carry the usual settings: role="status" is polite and atomic, re-reading the region after a pause; role="alert" interrupts.The React trap is timing. A live region only announces changes made after the browser is already observing it, and {msg && <p role="status">{msg}</p>} creates the region and its text in one commit, which screen readers routinely miss. Render the container from the first render; change only what is inside.function Search({ cities }) {
const [q, setQ] = useState('');
const hits = cities.filter(c => c.toLowerCase().includes(q.toLowerCase()));
return <div>
<label htmlFor="q">Filter cities</label>
<input id="q" value={q} onChange={e => setQ(e.target.value)} />
{/* present from the first render, so later text changes are announced */}
<p role="status">{q && `${hits.length} of ${cities.length} match`}</p>
<ul>{hits.map(c => <li key={c}>{c}</li>)}</ul>
</div>;
}
status live=polite atomic=True relevant=additions text
StaticText "2 of 5 match"
2.14.6 Auditing an Application
Auditing works in three layers: a linter on your JSX, an engine on the rendered accessibility tree, and a human with a keyboard. Each catches what the others cannot. Take a signup card with four defects â a bare <input>, an icon-only <button> holding nothing but an SVG, #a9a9a9 helper text on white, and on line 7 a <div onClick> as the submit button.The linter runs first because it works on source. Install eslint-plugin-jsx-a11y (6.10.2) and spread jsxA11y.flatConfigs.recommended.rules into eslint.config.js. ESLint 9 reports the fake button, twice (messages wrapped to fit):src\Signup.jsx
7:7 error Visible, non-interactive elements with click handlers must have
at least one keyboard listener jsx-a11y/click-events-have-key-events
7:7 error Avoid non-native interactive elements. If using native HTML is not
possible, add an appropriate role... jsx-a11y/no-static-element-interactions
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(process.argv[2]);
await page.waitForSelector('button');
const { violations } = await new AxeBuilder({ page }).analyze();
console.log(`${violations.length} violations`);
for (const v of violations)
console.log(`${v.impact}\t${v.id}\t${v.nodes[0].target}\t${v.help}`);
$ node a11y.js before.html
3 violations
critical button-name button Buttons must have discernible text
serious color-contrast p Elements must meet minimum color contrast thresholds
critical label input Form elements must have labels
$ node a11y.js after.html
0 violations