Skip to content

Accessible React

Every React app renders to the same HTML the browser has always shipped, and every assistive tool works from the DOM your components produce. Accessibility in React is mostly a series of small choices about that DOM: which element you render, how it gets its name, and what you do when the screen changes underneath someone who cannot see it change.

One JSX detail before the rest. React renames class to className and for to htmlFor, but ARIA attributes keep their hyphens: aria-live, aria-label, and a plain role.

Semantic elements come first

A <button> arrives with a pile of behavior already attached. It sits in the tab order, so a keyboard reaches it. It fires its click handler on Enter and on Space. A screen reader announces it as a button and reads its text as the name, which is also the name voice control software targets. The browser handles the disabled state, the focus ring, and the active styling.

jsx
// the browser gives you focus, keyboard activation, and the "button" announcement
<button className="die" onClick={hold}>{value}</button>

A <div> carrying an onClick handler gets one item from that list: the click. Tab skips past it, Enter and Space do nothing, and a screen reader reads it as a run of text with no hint that anything will happen if you interact with it.

The usual patch is role="button" plus tabIndex={0}, which puts the element in the tab order and changes what gets announced. Behavior is still missing. You would add an onKeyDown handler, check for Enter and for Space, call preventDefault() on Space so the page stops scrolling, and then keep a hand-rolled disabled state in sync with the styling. That is a fair amount of code to rebuild something the browser already ships. Reaching for the real <button> is the shorter path, and it stays correct as browsers change.

The same logic runs through the rest of the markup: <a href> for navigation, <nav> and <main> as landmarks a screen reader can jump between, headings in order for the outline people navigate by. Most accessibility work in a React codebase is choosing the element that already does the job.

Announcing what changed

A single-page app updates in place. There is no page load to tell a screen reader that something happened, so a change rendered halfway down the screen can be completely silent. A live region hands that information over: a container the screen reader watches and announces whenever its contents change. The sr-only class below hides it visually, using a CSS pattern covered later in this chapter.

jsx
<div aria-live="polite" className="sr-only">
  {isGameWon && <p>You won! Press New Game to start again.</p>}
</div>

The wrapper renders every time, empty at first, and React swaps a paragraph into it when isGameWon flips. That ordering is the part people get wrong. The element carrying aria-live has to be in the DOM before the content arrives, because screen readers register live regions when they encounter them and then watch for mutations. Mount the region and its text together in a single render and many screen readers announce nothing at all: the whole thing looks like ordinary new content. Keeping an empty region in the tree costs nothing and makes the announcement reliable.

aria-live="polite" puts the announcement in a queue. The screen reader finishes whatever it is currently reading, then delivers your message at the next natural pause, which may land a beat after the visual change. That delay is deliberate, and polite is the right setting for almost everything.

Keyboard interaction

Tab moves forward through focusable elements, Shift+Tab moves back, Enter activates links and buttons, and Space activates buttons and toggles checkboxes.

Tab order follows DOM order, so the sequence your JSX renders is the sequence people move through. Reordering visually with CSS leaves a tab order that jumps around the screen, and positive tabIndex values cause the same confusion on purpose. tabIndex={-1} is the useful one: it makes an element focusable from JavaScript while keeping it out of the tab sequence, which is what a focus target like a dialog heading needs.

Two more rules. Keep focus visible: avoid outline: none unless a :focus-visible style of your own replaces it. And keep an exit available: a modal that deliberately holds focus inside itself needs Escape to close and needs to hand focus back to its trigger.

Moving focus deliberately

When the UI changes shape, focus can end up nowhere. Someone activates a button, the button is removed or replaced, and focus falls back to <body>. The next Tab starts at the top of the page, and the reader has lost their place.

The fix is to move focus somewhere sensible, which is one of the legitimate uses of a ref:

jsx
function NewGameButton({ isGameWon, onNewGame }) {
  const buttonRef = useRef(null)

  useEffect(() => {
    if (isGameWon) {
      buttonRef.current.focus()
    }
  }, [isGameWon])

  return <button ref={buttonRef} onClick={onNewGame}>New Game</button>
}

The effect runs after React has committed that node to the screen, so the element is there to receive focus. Guarding on isGameWon keeps it from stealing focus on every render.

The same pattern covers the other common moments: a dialog takes focus on open and hands it back to the trigger on close, a validation failure sends focus to the first invalid field, deleting a row moves focus to the row that replaced it. The rule underneath is one line: if your code removed the thing that had focus, your code decides where focus goes next.

Visually hidden text

Plenty of status is obvious from the layout and silent to a screen reader: a green check beside a field, a die that looks pressed, a number that reads clearly from where it sits. Visually hidden text spells that out for anyone listening to the page.

The convention is a class called sr-only. It has no meaning to React or to the browser: it is a plain class name, and these CSS rules are what do the work.

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

The element stays in the accessibility tree while taking up no visual space. display: none and visibility: hidden would strip it from that tree too, hiding it from everyone.

An icon-only button is the everyday case. Either give it an aria-label, or put real text inside and hide it visually:

jsx
<button onClick={onClose}>
  <XIcon aria-hidden="true" />
  <span className="sr-only">Close</span>
</button>

aria-hidden="true" keeps the decorative SVG out of the announcement, and the hidden span supplies the name. One caution about aria-label: it sets an accessible name on interactive elements and on anything carrying an explicit role, and browsers frequently ignore it on a plain <div> or <span> with no role. Keep it to buttons, links, inputs, and labeled landmarks.

Reading the source will only take you so far with any of this. Turn VoiceOver on with Cmd+F5 and listen to your own app, and run axe DevTools in the browser to catch missing labels and unnamed controls automatically.

Every form control needs a label, and forms are where the gap shows up most often. A <label> tied to an input gives the field its accessible name, so a screen reader reads "Email address, edit text" when focus lands there, and the label text becomes a click target for the field.

Two wirings work. Point the label at the input by id, using React's htmlFor for the HTML for attribute:

jsx
<label htmlFor="email">Email address</label>
<input id="email" type="email" name="email" />

Or wrap the input in the label and skip the id entirely:

jsx
<label>
  Email address
  <input type="email" name="email" />
</label>

Wrapping suits a checkbox or a radio, where the text already sits beside the control. The htmlFor version gives you freer rein over layout.

A hard-coded id like email holds up for one form on one page. Lift that markup into a reusable <TextField> and two instances on the same page emit the same id, so htmlFor binds to whichever rendered first and the label quietly stops working for every field after it. useId generates an id that is unique per component instance, which is the job React added it for:

jsx
function TextField({ label, ...props }) {
  const id = useId()

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} {...props} />
    </>
  )
}

Suffix that value for related ids, ${id}-hint for a description element, so one call covers the whole control.

Placeholder text does a different job. A placeholder vanishes the instant someone types a character, so when it carries the only description of the field, that description disappears at the moment it is needed to check the answer. Default placeholder styling is light gray, which typically fails contrast requirements, and screen reader support for the attribute is inconsistent. Use it for an example of the expected format, [email protected] under a label reading "Email address".

Extra help text and error messages attach with aria-describedby, which points at the id of the element holding the text:

jsx
<label htmlFor="password">Password</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
  aria-invalid={error ? true : undefined}
/>
<p id="password-hint">{error || 'At least 12 characters.'}</p>

The description is read after the label and the field type, so it arrives as context rather than as the name. aria-invalid marks the field as failing validation, and swapping the error text into the element aria-describedby already points at keeps the announcement on a node the screen reader is tracking. One level up, a set of radio buttons belongs inside a <fieldset> with a <legend> carrying the question.

aria-live takes three values, and the choice decides whether the region helps or hurts. off is the default, meaning changes go unannounced. polite queues the announcement and delivers it when the screen reader reaches a pause in whatever it is already saying. assertive interrupts, cutting off the current announcement to deliver yours. Assertive is nearly always the wrong choice: reserve it for something that genuinely blocks the person's progress, like a session expiring in ten seconds. A save confirmation, a search result count, a game state change all belong in a polite region.

Two roles carry implied politeness and tend to be announced more consistently than a bare aria-live attribute: role="status" behaves as polite, role="alert" as assertive, and role="status" plus aria-live="polite" is a solid default for a status region. aria-atomic="true" then reads the region's whole contents on any change, which suits a short sentence that only makes sense whole; the default reads just what changed, which suits a log where each line stands alone.

The failure mode worth naming is the region that announces too much. Wire one to a value that updates on every keystroke, say a result count under a search box, and every character queues another announcement. Polite delivery adds to the queue instead of replacing it, so the person hears a stream of stale numbers over the field they are still typing in, and their own typing echo gets buried. A flickering loading flag or three competing regions cause the same pile-up.

So keep live regions few, debounce anything driven by typing until the value settles, and announce only the moments that would make a sighted user look up. An app that says nothing is at least explorable: the person can navigate it with their screen reader's own commands at their own pace. An app that talks constantly is one they leave.

JunoThe right element does most of the work Reach for a real button when something is clickable, and a real label next to every input. Those elements come with keyboard support and a name that a screen reader can read, all for free. When something changes on screen that a person listening to the page would otherwise miss, put a short sentence inside a div with aria-live="polite", and keep that div on the page from the start so the change is noticed.
JunoThe right element does most of the work Semantic elements give you focus, keyboard activation, and announcements with zero code, which is why patching a div with role and tabIndex leaves you writing your own key handling. Label every control with htmlFor or a wrapping label, and treat a placeholder as a format hint, since it disappears the moment someone types. Keep an aria-live="polite" region mounted and swap its text, and move focus with a ref whenever your code removes the thing that had it.
JunoThe right element does most of the work Live regions are registered when the screen reader encounters them, so the region must be in the DOM before the content changes, and polite delivers at the next pause in speech while assertive interrupts and is almost always the wrong pick. role="status" and role="alert" carry the same politeness with better consistency, and aria-atomic decides whether the whole region or only the delta gets read. An over-eager region driven by keystrokes queues announcements faster than they can be spoken, which is worse for the user than silence.

Next up: Beyond the basics, a map of what comes after the fundamentals.