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></>;

Listing 2.114: Heading levels computed from context The tree shows landmarks and an outline stepping 1, 2, 3 although no component wrote a number. The skip link comes first, or keyboard users retraverse the navigation on every route change.

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

Output of Listing 2.114 Older advice says a wrapper <div> around <li> breaks semantics. Chromium now ignores the generic wrapper and the list/listitem relationship survives — use Fragments anyway, for flex and grid parenting.

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>;

}

Listing 2.115: A field component that wires up its own label, hint and error Render two — one with a hint, one submitted empty — and both strings arrive as the field's description, apart from its name. Chromium's tree prints the second field as textbox "Email" invalid=true desc="Enter a work email.", which is exactly what a screen reader reads out: the name first, the state, then the description.aria-label on a visible control replaces that text for screen reader users and breaks voice control, which matches spoken words against what is on screen; reserve it for icon-only buttons, and mark the icon aria-hidden="true". role is a last resort: role="button" on a <div> promises keyboard behavior you must then write by hand. Name precedence runs aria-labelledby, aria-label, <label>, placeholder.

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>

Listing 2.116: A toolbar with a roving tabindex 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>;

}

Listing 2.117: A modal dialog that takes focus and gives it back Dump the tree while that dialog is open and it holds one subtree — dialog "Rename file" with its heading, textbox and buttons. The button that opened it is still in the DOM but gone from the tree, so nothing behind the overlay is reachable.For overlays you cannot build on <dialog> — a command palette, a combobox popup, a portal drawer — use a library instead of writing the trap: focus-trap-react 12.0.3 and react-focus-lock 2.13.7 (both MIT), @radix-ui/react-dialog 1.1.23 (MIT), or react-aria-components 1.21.1 (Apache-2.0).On a route change, give the new <h1> tabIndex={-1} and focus() it in an effect: focus moves without the heading joining the tab order. Never delete focus outlines — restyle :focus-visible instead.

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>;

}

Listing 2.118: A results count that announces itself After typing os, the region carries the settings its role implies, and the new text is what gets spoken:

status live=polite atomic=True relevant=additions text

StaticText "2 of 5 match"

Output of Listing 2.118 Use live regions sparingly. Everything polite queues, so a region updated on every keystroke builds a backlog the user cannot interrupt: debounce the message or derive it from useDeferredValue (Section 2.8.4). Keep role="alert" for submission failures, and handle route changes by moving focus (Section 2.14.4). Under the hood the browser diffs the region's subtree against its previous contents and hands the changed text to the platform accessibility API — it never sees React state, only DOM changes.

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

Output of Listing 2.118 Layer two is axe-core, Deque's rules engine (github.com/dequelabs/axe-core (https://github.com/dequelabs/axe-core), 4.13.0, MPL-2.0). It walks a real browser's accessibility tree, so it sees computed color, computed names and ARIA that exists only after a render. @axe-core/playwright wires it into a page; wait for a committed node or you audit an empty root.

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}`);

Listing 2.119: Running axe-core against a rendered React page Run that against the card, then against a copy with <label htmlFor>, aria-label="Dismiss" and aria-hidden="true" on the icon button, and #595959 helper text:

$ 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

Output of Listing 2.119 Compare the reports. axe found the three defects the linter missed and said nothing about the <div onClick>: with no role and no name it is indistinguishable from decoration. The linter found only that one. Zero violations means no rule fired — axe-core's README claims 57% of WCAG issues on average, and no engine judges whether aria-label="Click here" is useful.So run all three: @axe-core/react for findings in the dev console, vitest-axe or jest-axe to fail the build on a regression (Section 2.15), then unplug the mouse and try a screen reader — NVDA, VoiceOver or JAWS. The DevTools accessibility pane shows the same tree these printouts came from.