Command Palette

Search for a command to run...

Hectal
PHASE 18Intermediate ~24 min· topic 3 of 5

Topic 18.3

ARIA in React

In one line

ARIA attributes add roles, names, relationships and states that HTML can't express; in React you write them as hyphenated aria-* props, connect elements with ids from useId, and announce async changes with live regions.

Think of it like this

Sticky notes on furniture for a friend who can't see. The chair is already a chair (that's HTML), but a note can say "this drawer holds the spoons" or "this box is open". ARIA is the sticky notes: it adds information for assistive technology. It never changes how things work: a note saying "lamp" on a box doesn't make it light up.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

ARIA
A set of attributes (role, aria-*) that add accessibility information HTML can't express on its own.
State (ARIA)
An attribute that changes as the user interacts: aria-expanded, aria-selected, aria-checked, aria-pressed.
aria-labelledby
Names an element using the text of other elements, given by their ids.
aria-describedby
Adds a description (a hint or an error) read after the name, from elements given by id.
aria-controls
Says which element this control shows, hides or changes (by id).
useId
A React hook that returns an id unique to this component instance, stable across renders and server/client.
Live region
An area whose text changes are read out by screen readers without moving focus.
role="status" / role="alert"
Ready-made live regions: polite (waits for a pause) and assertive (interrupts).
aria-hidden
Removes an element and its children from the accessibility tree. It does not stop focus.

Step by step

01Write aria props the HTML way

Most JSX props are camelCase (className, tabIndex, onClick), but aria-* and data-* stay exactly as in HTML. Pass state values directly: React turns true/false into the strings ARIA expects.

Toggle.jsxwhole filejsx
<button
  aria-pressed={isBold}        // renders aria-pressed="true" or "false"
  aria-label="Bold"            // name for an icon-only button
  onClick={() => setBold(!isBold)}
>
  <BoldIcon aria-hidden="true" />
</button>

02Connect label, hint and error with useId

Call useId() once per component and derive every related id from it. The input points at the hint (always) and the error (only when there is one) with aria-describedby, which takes a space-separated list. aria-invalid marks the field as wrong.

Connect label, hint and error with useIddiagram
Rendering diagram…

03Announce async results with a live region

Render <p role="status"> from the first render, empty. When a search starts, set its text to Searching…; when results arrive, set it to 4 results. Don't render the status element conditionally, and don't put the whole results list inside it (everything inside would be read).

Announce async results with a live regiondiagram
Rendering diagram…

04Check: is there a native element?

Before adding a role, ask whether HTML already has it: <button> (not role="button"), <nav> (not role="navigation"), <dialog>, <details>, <progress>, <meter>, <input type="checkbox"> (not role="checkbox"), <output> (a live region for calculated results). Use ARIA for what's left: tabs, comboboxes, menus, aria-current, aria-pressed, aria-expanded.

Try it yourself

  1. 1

    Add a third field

    In the useId example, add <Field label="Phone" hint="With country code." />. Predict its id before you run. (_r_2_.) Then blur the Email field and watch its border and description change.

  2. 2

    Make the live region conditional

    In the search example, change the status line to {status && <p role="status">{status}</p>}. The log still shows both messages (the observer sees every change), but think about a real screen reader: the region now appears at the same moment as its first text, and many screen readers miss that first announcement. Put it back.

  3. 3

    Try a misspelling

    In the last example, rename aria-controls to aria-control. Predict what the console shows. (React warns in the console that aria-control is an invalid aria prop on the <button> tag, and the button loses its link to the panel.)

Code & diagrams

useId links label, hint and error in every instance React 18+ New tab

Two `Field`s, two different ids, no collisions. The last line is what a screen reader reads after the field's name: the hint, then the error. Styling `input[aria-invalid="true"]` keeps the visual state and the announced state in one place.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Name → id _r_0_ | describedby: _r_0_-hint
Email → id _r_1_ | describedby: _r_1_-hint
after blur: aria-invalid = true
description read out: As on your ID card. Name is required.
A live region announces async results New tab

The MutationObserver plays the role of a screen reader watching the region. The list itself is not in the live region: only the short summary is announced, and users can then move into the list.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

live region now says: Searching…
live region now says: 2 results
How React writes aria props New tab

`aria-*` booleans become the strings `"true"`/`"false"` (`aria-hidden={false}` writes `"false"`, it does not remove the attribute). `hidden`, a normal HTML boolean, is added or removed instead. Pass `undefined` to drop an aria attribute.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

<button aria-expanded="false" aria-controls="more">More options</button>
after click: aria-expanded = true
<div id="more" aria-hidden="false">Extra settings…</div>

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Misspell aria props

Write aria-labeledby="x" (one l) and ariaLabel="hi" (camelCase) on a div.

terminal
$ Console
── what you'll see ──
Invalid ARIA attribute `ariaLabel`. Did you mean `aria-label`?
Invalid aria prop `aria-labeledby` on <div> tag. For details, see https://react.dev/link/invalid-aria-props

Break #2

Hard-code ids in a reusable component

A Field component uses <label htmlFor="field"> and <input id="field">, and a form renders it three times.

terminal
$ Click the second field's label
── what you'll see ──
Focus jumps to the FIRST field. Screen readers announce the second and third inputs with no name, and the page has duplicate ids.

Myth vs fact

Myth

More ARIA makes a page more accessible.

Fact

ARIA only changes announcements. Wrong or unnecessary roles mislead users and remove native semantics. Use native elements first and ARIA only to fill gaps.

Myth

aria-hidden="true" hides an element from everyone using assistive technology, so it's safe anywhere.

Fact

It hides it from screen readers but not from the keyboard. On a focusable element, keyboard users land on something screen readers can't describe. Use inert or hidden instead.

Myth

useId is a good way to make list keys.

Fact

useId gives one id per component instance, not per item, and isn't meant for keys. Keys come from your data (Topic 3.3).

Myth

role="alert" is the right way to show any message.

Fact

It interrupts whatever the user is hearing. Use role="status" for normal confirmations and results; save alert for urgent errors.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    useId works from the component's position in the tree, which is why it's stable between server and client renders (Topic 20.6). With several React roots on one page, pass identifierPrefix to createRoot/hydrateRoot so their ids can't collide. Its format has changed between versions (:r0: in React 18, _r_0_ in React 19.2, which is valid in CSS selectors), so never parse it.

  • ▸

    Screen readers handle live regions differently. Reliable habits: mount the region early and keep it mounted; change its text rather than replacing the element; keep messages short; and when the same message repeats ("Saved" twice), clear the text first or add a counter, because some readers ignore an unchanged string. aria-atomic="true" makes the whole region be read on any change, not just the changed node.

  • ▸

    React 19's <form action> and useActionState (Phase 19) return server errors asynchronously: put the error summary in a live region or move focus to it, and link each invalid field to its message with aria-describedby. aria-errormessage exists too, but aria-describedby has wider screen reader support today.

  • ▸

    aria-activedescendant (used by comboboxes and listboxes, Topic 18.4) lets focus stay on an input while a screen reader announces an option in a popup list. It needs ids for every option, which is another reason to derive them from useId.

Remember this

  1. 1

    ARIA (Accessible Rich Internet Applications) is a W3C standard of attributes that tell assistive technology things HTML alone can't: a role (role="tab"), properties (aria-controls, aria-describedby, aria-labelledby), and states that change (aria-expanded, aria-selected, aria-pressed, aria-invalid, aria-current). The HTML course (/html, Topic 7.4) teaches the attributes; here you'll see how React writes and updates them.

  2. 2

    In JSX, **aria-* and data-* attributes keep their HTML spelling**: hyphenated and lowercase (aria-label, aria-describedby), unlike most props, which are camelCase. React passes them straight to the DOM. A boolean value is written as the string "true" or "false": aria-expanded={open} renders aria-expanded="false" when closed, which is what ARIA wants (an explicit false). To remove an attribute entirely, pass undefined (for example aria-invalid={error ? true : undefined}). The development build warns about typos such as aria-labeledby and camelCase mistakes such as ariaLabel.

  3. 3

    Relationships need ids. aria-labelledby, aria-describedby, aria-controls and htmlFor point to other elements by id, and ids must be unique on the page. A component used twice with a hard-coded id="email-hint" produces duplicates, and the second field silently points at the first field's hint. **useId()** (React 18) returns a string that is unique for each component instance, the same on every render, and the same on the server and the client (so server-rendered HTML matches). Build related ids from it: id + "-hint", id + "-error". Don't use it for list keys, and don't depend on its exact format (it changed between versions; React 19.2 produces ids like _r_0_).

  4. 4

    Live regions make screen readers announce text that changes without focus moving there: "Saved", "3 results", "Upload failed". role="status" (implicitly aria-live="polite": announced when the user is idle) is for most messages; role="alert" (implicitly aria-live="assertive": interrupts) is for urgent errors only. The rule that trips React developers: the live region must already be in the page before its text changes. Screen readers watch existing regions for changes; a region that mounts together with its message is often not announced. So render the empty container always and change only its text. WCAG 4.1.3 Status Messages (AA) requires this kind of announcement for status messages.

  5. 5

    Use a role only when HTML has no element for it. The first rule of ARIA: if a native element with the semantics and behaviour exists, use it. role="button" on a div, role="link" on a span, role="heading" with aria-level instead of <h2>: all worse than the real element. ARIA is needed for patterns HTML lacks (tabs, tree views, comboboxes with custom popups, aria-current="page" on the active nav link, aria-pressed on toggle buttons) and for naming icon-only controls (aria-label). People summarise it as "no ARIA is better than bad ARIA": wrong roles make things worse than none.

  6. 6

    Common hazards: aria-hidden="true" hides an element and all its children from assistive technology, so never put it on something focusable (keyboard users land on an element screen readers can't see; use inert to hide whole regions). aria-label on a generic div or span with no role is ignored by many screen readers. A role overrides the element's own: <button role="tab"> is announced as a tab, and promises tab keyboard behaviour that you must then implement (Topic 18.4).

Explain it without notes

01

How do you write ARIA attributes in JSX, and how does React render boolean values for them?

02

Why does a reusable component need useId for accessibility, and what properties does useId guarantee?

03

What is a live region, and what mistake makes React live regions silent?

04

What is the first rule of ARIA? Give two cases where ARIA is genuinely needed.

Practice

01

Write a Toggle button component for "Mute" that exposes its on/off state to screen readers.

02

Write a SaveStatus component that announces Saving…, then Saved after a fake 500 ms save.

Trade-offs

  • ↔

    Focusing an error summary on submit is more noticeable than a live region but moves the user away from the field they were on. Common practice: on submit, focus the first invalid field (its aria-describedby reads the error); for async status, use a live region.

  • ↔

    aria-label is quick but invisible, can't be translated by some browser tools and may drift from the visible text. Prefer visible text or aria-labelledby pointing at visible text.

Done when you can

  • I write aria-* props correctly in JSX and know how React renders booleans.

  • I use useId to link labels, hints and errors in reusable components.

  • I keep live regions mounted and update their text for async results.

  • I choose role="status" or role="alert" correctly.

  • I reach for native elements first and add a role only when HTML has none.