Ch. 3 · React

React Live Regions and Screen Reader Announcements

Announce cart updates, result counts and errors to screen readers from React: why live regions must already exist, and how to repeat a message.

~6 min readadvancedupdated Oct 19, 2026

A sighted user who clicks “Add to cart” sees the cart badge change from 2 to 3. A screen reader user hears nothing, because the focus stayed on the button and nothing about the button changed. Neither does typing into a search box announce that the result list now has 12 items, or does a toast saying “Payment failed” get read unless it’s built for it. Single-page apps change the screen constantly without moving focus, and live regions are how you tell assistive technology about those changes.

The ARIA side is short to describe and easy to get subtly wrong in React, because React’s habit of mounting and unmounting elements works against how screen readers watch the page. That gap is what interviewers probe.

How a live region works

A live region is an element whose text changes the screen reader should speak, even though focus is elsewhere. You mark it with aria-live="polite" or aria-live="assertive", or with a role that implies one:

  • role="status" implies aria-live="polite" (and aria-atomic="true"). The screen reader waits until the user pauses, then reads the change. Use it for confirmations, result counts and progress.
  • role="alert" implies aria-live="assertive". It interrupts whatever is being read. Reserve it for things that need immediate attention, such as a failed payment or a session about to expire.

The key behaviour: the screen reader registers the region when it appears, then announces changes to its content. That leads to the first React trap.

Why conditional rendering is often silent

This is the natural way to write a confirmation in React, and it frequently announces nothing:

{added && <p role="status">Blue mug added to cart</p>}
JSX

When added becomes true, React inserts a brand-new element that already contains the text. From the screen reader’s point of view, a region appeared; its content didn’t change. Some combinations of browser and screen reader announce it anyway, others don’t, and you can’t control which one your users have. The reliable pattern is to render the region empty from the start and change only its text:

<p role="status">{added ? 'Blue mug added to cart' : ''}</p>
JSX

role="alert" is the partial exception: many screen readers do read an alert that’s inserted with content. Even so, an always-present container is the predictable choice.

The second trap is repetition. Click “Add to cart” twice and the message is the same string both times. React sees no change in the text, doesn’t touch the DOM, and nothing is announced the second time. Even if you force a re-render that rewrites identical text, screen readers commonly ignore it. The user who added two mugs heard about one.

Building one announcer for the whole app

Scattering live regions through components invites both traps and gives you regions that compete with each other. A single announcer mounted near the root, with a hook to call it, solves both:

import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';

const AnnounceContext = createContext(() => {});
export const useAnnounce = () => useContext(AnnounceContext);

export function Announcer({ children }) {
  const [messages, setMessages] = useState({ polite: '', assertive: '' });
  const timers = useRef({});

  const announce = useCallback((text, priority = 'polite') => {
    clearTimeout(timers.current[priority]);
    setMessages((m) => ({ ...m, [priority]: '' }));
    timers.current[priority] = setTimeout(
      () => setMessages((m) => ({ ...m, [priority]: text })),
      150,
    );
  }, []);

  useEffect(() => () => Object.values(timers.current).forEach(clearTimeout), []);

  return (
    <AnnounceContext.Provider value={announce}>
      {children}
      <div className="sr-only" role="status">{messages.polite}</div>
      <div className="sr-only" role="alert">{messages.assertive}</div>
    </AnnounceContext.Provider>
  );
}
JSX

And a consumer:

function AddToCart({ product }) {
  const announce = useAnnounce();
  return (
    <button onClick={() => { addItem(product); announce(`${product.name} added to cart`); }}>
      Add to cart
    </button>
  );
}
JSX

Walk through the decisions.

Both regions render from the first paint, empty. They’re registered before any message arrives, so every later change is a change.

announce clears first, then sets after 150 ms. Clearing makes the next message a genuine content change even if it’s identical to the last. The delay gives the accessibility tree time to register the empty state; setting both in the same render would be batched by React into a single update and the clear would never reach the DOM. There’s no magic number, but somewhere around 100 to 200 ms works in practice without feeling laggy.

useCallback with no dependencies. The announce function is stable, so components that receive it don’t re-render when messages change, and the context value never changes identity.

Timers are tracked and cleaned up. Two quick calls cancel the first pending message, so only the latest is read, and unmounting the announcer doesn’t leave a timeout calling setState on nothing.

sr-only hides the text visually, not from assistive tech. Use the usual clip-based utility class (position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%)). display: none or hidden would remove the region from the accessibility tree and nothing would be announced.

Running this in jsdom with React 19 and clicking the button twice, the status region’s text goes "" straight after each click, then "Blue mug added to cart" 200 ms later, both times. The DOM transition is exactly what we want. Whether a particular screen reader speaks it is a separate question that jsdom can’t answer; check with VoiceOver on macOS or iOS and NVDA on Windows before you rely on it.

Choosing what to announce

Announcing too much is as bad as too little. A few guidelines:

  • Search-as-you-type: announce the result count once typing pauses (“12 results for ‘mug’”), not on every keystroke. Debounce by 500 ms or so, and make it polite.
  • Form submission errors: move focus to an error summary or the first invalid field instead. Focus movement is announced naturally and lets the user act. Use a live region for errors only when moving focus would be disruptive.
  • Loading states: “Loading results” is rarely worth saying if the results arrive in under a second. Announce completion instead, or announce loading only if it takes longer than a threshold.
  • Toasts: a toast that disappears after four seconds may vanish before a screen reader gets to it. Route its text through the announcer so the message is spoken even after the visual toast is gone.
  • Route changes: a client-side route change doesn’t trigger the page-load announcement a full reload would. Move focus to the new page’s <h1> (with tabIndex={-1}), or announce the new page title.

Don’t put interactive content in a live region, and don’t wrap large, frequently updating areas like a whole chat panel in aria-live without thinking; the screen reader may read the entire block on each change. If you only want a part read, keep the live region small, or use aria-atomic deliberately.

Practice question: the double “added”

“QA reports that with VoiceOver, adding the same item to the cart twice only announces once, and on a product page the ‘Added’ confirmation is never announced at all. The code is {justAdded && <span role="status">Added</span>}. What’s going on?”

Answer. Both bugs come from how the region is rendered. On the product page, the span with role="status" is mounted at the same moment it gets its text, so VoiceOver sees a new region rather than a change to an existing one, and stays silent. On repeat adds, the text “Added” doesn’t change, so even if the element stays mounted, React doesn’t update the DOM and the screen reader has nothing to announce. I’d replace the per-component span with an app-level announcer: an always-rendered, visually hidden role="status" element near the root, and a useAnnounce() hook that clears the text, waits around 150 ms, then sets the new message, with the product name in it so the message is informative (“Blue mug added to cart, 3 items”). Then I’d verify with VoiceOver and NVDA, because a jsdom test can confirm the DOM sequence but not the speech.

More on this

More in React

read ✓React · hard

React Dialogs: Focus, Escape and Restoration

Build an accessible React modal: native dialog with showModal, focus on open, Escape to close, focus restoration and testing with keyboard only.

~7 min readread →
read ✓React · hard

React Context Splitting for Performance

Why every React context consumer re-renders when any part of the value changes, and how splitting state, actions and slow-changing data fixes it.

~6 min readread →
esc