A13 · Part 3 · Advanced
Accessibility
Semantics first, keyboard second, ARIA last — and focus management is the part React makes harder.
10On this page
The order that matters
- Semantics. Use the element that already does the job.
- Keyboard. Everything reachable, focus visible, order sensible.
- Names and state. Every control has an accessible name; anything that toggles says so.
- Contrast and motion. 4.5:1 for body text; respect
prefers-reduced-motion.
Working down that list fixes more than any amount of ARIA sprinkled on at the end — and each step is checkable in a minute.
The best ARIA is the ARIA you did not need
<div onClick={save}>Save</div> // ✗ invisible to keyboards
<div role="button" tabIndex={0} onClick={save}
onKeyDown={(e) => e.key === "Enter" && save()}> // ✗ still not a button
<button onClick={save}>Save</button> // ✓
A real <button> is focusable, activates on Enter
and Space, announces itself as a button, participates in forms,
supports disabled, and shows a focus ring. The div version
reimplements four of those and forgets the rest.
Names
<button><TrashIcon /></button> // ✗ announced as "button"
<button aria-label="Delete task"><TrashIcon /></button> // ✓
<label htmlFor="email">Email</label><input id="email" /> // ✓ best
<input placeholder="Email" /> // ✗ not a label
An icon-only control with no name is the single most common failure in React
apps, and it is the one a role-based test catches for free —
getByRole("button", { name: /delete/i }) simply will not find it.
useId: labels in a reusable component
function Field({ label, ...rest }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} {...rest} />
</>
);
}
A hardcoded id breaks the moment the component is rendered twice
— duplicate ids, and the second label points at the first input.
Math.random() breaks server rendering, because the server and the
client generate different values and hydration mismatches.
useId generates an id that is stable across renders and
identical on the server and the client, which is exactly the guarantee
the other two options fail to give.
const id = useId();
<input aria-describedby={id + "-hint"} />
<p id={id + "-hint"}>Must be at least 8 characters</p> // derive, don't call twice
Call it once per component and derive related ids by suffixing. It is for linking elements to each other — not for list keys, where you want the data's own id.
Focus management: React's specific problem
In a server-rendered site, clicking a link loads a new document and the browser resets focus. In a SPA nothing moves, so a screen-reader user navigates and is told nothing happened.
function Route({ children }) {
const ref = useRef(null);
const { pathname } = useLocation();
useEffect(() => {
ref.current?.focus(); // move focus to the new page's heading
}, [pathname]);
return <main ref={ref} tabIndex={-1}>{children}</main>;
}
tabIndex={-1} makes an element programmatically focusable without
adding it to the tab order. It is the standard trick for focus targets.
Dialogs, in full
- Focus moves into the dialog on open — the first control, or the heading.
- Focus is trapped while open; Tab cycles inside it.
- Escape closes it.
- Focus returns to the element that opened it.
- The background is inert, so a screen reader cannot wander behind it.
role="dialog",aria-modal="true", and a label.
<dialog ref={ref}>...</dialog>
ref.current.showModal(); // focus, Escape, inertness, top layer — all free
The native element does the first five. It is the reason to reach for it before writing any of this by hand.
Announcing things that change
<div aria-live="polite">{status}</div> // "Saved" — waits for a pause
<div role="alert">{error}</div> // interrupts — for errors only
A toast that appears with no live region is invisible to a screen reader. The container must exist in the DOM before the text is inserted — rendering the whole region conditionally means the announcement is missed.
Conditional rendering and the disappearing focus
{isEditing ? <Input /> : <button onClick={edit}>Edit</button>}
Click Edit and the button unmounts. Focus was on it, so focus falls back to
<body> — a keyboard user is now at the top of the
document with no idea what happened. Move focus deliberately to the input,
and back to the button when editing ends.
Forms
<input
id="email"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : undefined}
autoComplete="email"
/>
{error && <p id="email-error">{error}</p>}
aria-describedby ties the message to the field so it is read with
it. On submit, move focus to the first invalid field — otherwise the
user is told there are errors and given no way to find them.
Do not disable the submit button to indicate invalidity. It cannot be focused, so it explains nothing to the person who most needs the explanation.
What tooling catches, and what it cannot
| Tool | Finds |
|---|---|
eslint-plugin-jsx-a11y | Missing alt text, invalid roles, a click handler on a div — while you type |
@axe-core/react, Lighthouse | Contrast, missing names, bad heading order, duplicate ids |
| Testing Library role queries | Controls with no accessible name, because the query fails |
| Your keyboard | Everything above misses: focus order, traps, whether the flow makes sense |
Automated checks catch roughly a third of real issues. The other two thirds need five minutes with the mouse pushed away.
Interview answer, one sentence
"Semantics first — most ARIA I see is fixing a div that should have been a button. The part specific to React is focus management: client-side navigation does not reset focus, and conditionally rendering a focused element drops focus to the body, so both need handling explicitly."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series