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>| Role | Politeness | Use it for | Risk |
|---|---|---|---|
status | polite | Result counts, saved confirmation, progress | Under-use means silent updates |
alert | assertive | Errors requiring action | Over-use interrupts and gets ignored |
log | polite | A chat message list | Every message announces; can be noisy |
timer | off by default | A countdown | Announcing every second is unusable |
progressbar | polite | Determinate progress | Needs 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);
});- Announce the outcome, not the mechanics. "Item added" is useful; "XHR complete with status 200" is not.
- Update one region with a complete sentence rather than several regions with fragments.
- For validation, announce once per failed submit rather than once per field.
- Clear the region before setting the same text again, or a repeat of the same message is not announced a second time.
- 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>| Change | Announce | Move focus |
|---|---|---|
| Full route change | The new page title | To the main heading |
| A panel or tab opens | Its name | Into the panel |
| Async list loads | The item count | No - leave focus alone |
| Form validation fails | A summary of the problems | To the summary |
| A row is deleted | The confirmation | To a sensible next item |
| A modal opens | The dialog title | Into 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.Related
Accessible components: modals, menus, tabs and comboboxes Accessibility in design systems and SPA frameworks
Last refreshed 2026-09-18.