Skip to the chapter
←ReactGGroundwork⚛React
React/Advanced/A13
46 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛A13 · Part 3 · Advanced

Accessibility

Semantics first, keyboard second, ARIA last — and focus management is the part React makes harder.

4 min read10 sections4 exercises
46 of 57

In this chapter

  • The order that matters
  • The best ARIA is the ARIA you did not need
  • Names
  • useId: labels in a reusable component
  • Focus management: React's specific problem

and 5 more

about 4 min
10On this page
  1. The order that matters
  2. The best ARIA is the ARIA you did not need
  3. Names
  4. useId: labels in a reusable component
  5. Focus management: React's specific problem
  6. Dialogs, in full
  7. Announcing things that change
  8. Conditional rendering and the disappearing focus
  9. Forms
  10. What tooling catches, and what it cannot

The order that matters

  1. Semantics. Use the element that already does the job.
  2. Keyboard. Everything reachable, focus visible, order sensible.
  3. Names and state. Every control has an accessible name; anything that toggles says so.
  4. 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

ToolFinds
eslint-plugin-jsx-a11yMissing alt text, invalid roles, a click handler on a div — while you type
@axe-core/react, LighthouseContrast, missing names, bad heading order, duplicate ids
Testing Library role queriesControls with no accessible name, because the query fails
Your keyboardEverything 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."

04Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 10 testsTabs that work with a keyboardadvanced
  • 11 testsAn accessible autocompleteadvanced
  • 5 testsAnnounce a status change without moving focusadvanced
  • 7 testsA div that behaves like a checkboxadvanced
All 54 for this level →
A13

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. A12Previous · A12Performance work
  2. A13you are here
  3. Next · A14Design systemsConstraints that make the common thing easy and the inconsistent thing hard.3 min read

01On this page

10 sections · 0%

  1. 01The order that matters
  2. 02The best ARIA is the ARIA you did not need
  3. 03Names
  4. 04useId: labels in a reusable component
  5. 05Focus management: React's specific problem
  6. 06Dialogs, in full
  7. 07Announcing things that change
  8. 08Conditional rendering and the disappearing focus
  9. 09Forms
  10. 10What tooling catches, and what it cannot

[] chapters · t top