Live regions and dynamic content announcements

Politeness levels, status and alert roles, announcing async results and validation, route changes in single-page apps, and avoiding announcement spam.

Live regions that work

<!-- polite: announced when the user is idle. Use for most updates. -->
<p role="status" aria-live="polite">2 results available</p>

<!-- assertive: interrupts immediately. Use only for errors that need action. -->
<div role="alert">Payment failed. Check your card details.</div>

<!-- a region must be in the DOM before the content is added -->
<div id="cart-status" role="status" aria-live="polite" aria-atomic="true"></div>

<!-- and a busy indicator that is announced once -->
<button type="button" aria-busy="true">Saving...</button>
RolePolitenessUse it forRisk
statuspoliteResult counts, saved confirmation, progressUnder-use means silent updates
alertassertiveErrors requiring actionOver-use interrupts and gets ignored
logpoliteA chat message listEvery message announces; can be noisy
timeroff by defaultA countdownAnnouncing every second is unusable
progressbarpoliteDeterminate progressNeeds a current value, not just a bar
  • The live region must exist in the DOM before the text is inserted. Adding the container and the text at the same time usually announces nothing.
  • aria-atomic="true" makes the reader announce the whole region rather than the changed node, which is usually what you want for a short status.
  • Never put a live region inside another live region. The result is duplicated or garbled announcements.
  • Debounce. Announcing on every keystroke of a search box makes the whole page unusable.

Async results and validation

const status = document.getElementById("cart-status");

// one announcement per outcome, not per network event
async function addToCart(sku) {
  status.textContent = "Adding item...";

  try {
    await api.addToCart(sku);
    status.textContent = "Item added. Cart now has 3 items.";
  } catch (err) {
    alertBox.textContent = "Could not add the item. Try again.";
  }
}

// a debounced search count
let timer;
input.addEventListener("input", () => {
  clearTimeout(timer);
  timer = setTimeout(() => {
    const n = document.querySelectorAll("#results li").length;
    status.textContent = n === 0 ? "No results" : n + " results available";
  }, 400);
});
  1. Announce the outcome, not the mechanics. "Item added" is useful; "XHR complete with status 200" is not.
  2. Update one region with a complete sentence rather than several regions with fragments.
  3. For validation, announce once per failed submit rather than once per field.
  4. Clear the region before setting the same text again, or a repeat of the same message is not announced a second time.
  5. If the action moves the user somewhere else, announce the destination instead of the action.
// forcing a repeat announcement of the same string
function announce(el, message) {
  el.textContent = "";                  // must reach the DOM before the new text
  requestAnimationFrame(() => { el.textContent = message; });
}

Route changes and page titles

// a client-side route change should behave like a page load
router.afterEach((to) => {
  const heading = document.querySelector("main h1");
  document.title = to.meta.title + " - Example";

  const announcer = document.getElementById("route-announcer");
  announcer.textContent = to.meta.title + " page loaded";

  // move focus so the next Tab starts from the new content
  heading?.setAttribute("tabindex", "-1");
  heading?.focus({ preventScroll: true });
});

// and an empty region, present from the first render, that carries the message
// <p id="route-announcer" role="status" aria-live="polite" class="sr-only"></p>
ChangeAnnounceMove focus
Full route changeThe new page titleTo the main heading
A panel or tab opensIts nameInto the panel
Async list loadsThe item countNo - leave focus alone
Form validation failsA summary of the problemsTo the summary
A row is deletedThe confirmationTo a sensible next item
A modal opensThe dialog titleInto the dialog

The rule that ties all of this together: after any change, the user must be able to tell what happened and where they now are. Focus and announcements are the two mechanisms, and a single-page app must deliberately do what a full page load would have done automatically.

⚠️
Over-announcing is as harmful as not announcing. A live region that fires on every filter change, every hover and every timer tick produces a continuous stream that forces the user to stop listening. Reserve assertive announcements for things that need immediate action, and keep everything else polite and batched.

FAQ

Why is my live region not announcing?
Almost always because the region was created at the same moment as its content, or because it is display: none when the text is set. Create the empty region first and keep it in the accessibility tree.
Should a single-page app reset the title?
Yes. Setting document.title on every route change is one of the highest-value and cheapest accessibility fixes in an SPA - the title is announced and it appears in the window list.

Accessible components: modals, menus, tabs and comboboxes Accessibility in design systems and SPA frameworks

Last refreshed 2026-09-18.