Mert Özen Further With Every Line
Frontend Accessibility (a11y) — #05

Dynamic Content and Screen Reader Announcements: Live Regions, ariaNotify(), Form Errors, Toasts and Loading States

Mert Özen Nov 15, 2026 18 min 5 views
Dynamic Content and Screen Reader Announcements: Live Regions, ariaNotify(), Form Errors, Toasts and Loading States

Messages that do not move focus (result counts, "added to cart", error counts) reach a screen reader through a live region or ariaNotify(), and a live region must be in the DOM before the change. In Bookstore we measure the role="status" inside @if trap, announce the result count with aria-busy, use ariaNotify() with a CDK LiveAnnouncer fallback, move focus to an error summary and the order confirmation, and look at toasts in terms of 2.2.1 and 4.1.3.

Version note: The examples in this post were run with WCAG 2.2, WAI-ARIA 1.2, Angular 22.2 (TypeScript 6.0), Angular CDK 22.2, Node.js 24, Playwright 1.65 (Chromium 158) and axe-core 4.14.

Guess First

On the book page of Bookstore, the online bookstore used as the shared example across this site's series, pressing "Add to cart" shows "Added. Go to cart" under the button. So that screen reader users hear it too, a developer adds one attribute to the paragraph:

@if (added()) {
  <p class="success" role="status">Added. <a routerLink="/cart">Go to cart</a></p>
}

Does the screen reader say "Added" now? What does the user hear if they press the same button a second time? What does the "3 books" line in the catalog need when the category changes? Before answering, one thing up front: no real screen reader session was run for this post either (that is the topic of the next post in the series). What we measure is the information the browser writes into the accessibility tree and into live regions.

What Is a Status Message? 4.1.3 and Live Regions

WCAG 2.2 criterion 4.1.3 Status Messages (AA) asks for this: messages that do not move focus and that tell the user the result of an action, a waiting state, progress or the existence of an error must be programmatically determinable through a role or property, so that a screen reader can read them without the user going there. W3C's Understanding page lists exactly our cases as examples: the number of results after a search, the count that updates after adding to a cart, the number of errors after a form submission, a "busy" indicator. Changes that move focus (a new page, a dialog that opens) are not what this criterion is about, because the screen reader already announces focus.

The tool for this is a live region: an area whose content the screen reader reads when it changes, without the user going there. aria-live="polite" waits until the current speech ends, assertive interrupts it. Ready-made roles carry these settings themselves: role="status" is polite, role="alert" is assertive, role="log" is polite and reads only added lines. aria-atomic="true" says that the whole region is read on a change, and aria-relevant says which kinds of change (additions, removals, text) count. The Understanding page also warns that using alert or assertive for unimportant messages keeps interrupting the user and is a failure in itself.

What can we measure without a screen reader? Two things: which live properties Chromium gives a node in the tree, and whether the page actually changes the text inside a live region. For the second we wrote a small recorder. A script injected into the page records ariaNotify() calls and text changes in live regions, and marks a region that was not in the DOM before the change as "missed":

// Records what Bookstore tries to tell a screen reader without moving focus: ariaNotify() calls and text changes
// inside live regions. This is a model of the ARIA rules, not a screen reader.
// Usage: node announce.mjs <book|catalog|busy|checkout|order> [no-arianotify]
import { chromium } from 'playwright';
import { base, focusedAx, open } from './lib.mjs';

function recorder() {
  window.__announcements = [];
  const log = entry => window.__announcements.push(entry);
  for (const proto of [Document.prototype, Element.prototype]) {
    const original = proto.ariaNotify;
    if (!original) continue;
    proto.ariaNotify = function (text, options) {
      log(`ariaNotify ${options?.priority ?? 'normal'}: "${text}"`);
      return original.call(this, text, options);
    };
  }
  const liveSelector = '[aria-live]:not([aria-live="off"]), [role="status"], [role="alert"], [role="log"]';
  const politeness = el => el.getAttribute('aria-live') ?? (el.getAttribute('role') === 'alert' ? 'assertive' : 'polite');
  // A region only reports changes if it was already in the DOM before the change
  const known = new WeakSet();
  const lastText = new WeakMap();
  const textOf = el => el.textContent.trim().replace(/\s+/g, ' ');
  const remember = () => document.querySelectorAll(liveSelector).forEach(el => {
    if (!known.has(el)) lastText.set(el, textOf(el));
    known.add(el);
  });
  const observer = new MutationObserver(mutations => {
    const changed = new Set();
    for (const m of mutations) {
      const node = m.target.nodeType === Node.ELEMENT_NODE ? m.target : m.target.parentElement;
      const region = node?.closest(liveSelector);
      if (region) changed.add(region);
      // aria-busy="false" on a container: the regions inside report what changed while it was busy
      if (m.attributeName === 'aria-busy') m.target.querySelectorAll(liveSelector).forEach(el => changed.add(el));
      for (const added of m.addedNodes) {
        if (added.nodeType === Node.ELEMENT_NODE && added.matches(liveSelector) && !known.has(added) && added.textContent.trim()) {
          log(`missed (region inserted with its text): ${added.getAttribute('role') ?? 'aria-live'} "${textOf(added)}"`);
        }
      }
    }
    for (const region of changed) {
      const text = textOf(region);
      if (known.has(region) && text && text !== lastText.get(region) && !region.closest('[aria-busy="true"]')) {
        lastText.set(region, text);
        log(`live ${politeness(region)}${region.getAttribute('aria-atomic') === 'true' ? ' atomic' : ''}: "${text}"`);
      }
    }
    remember();
  });
  document.addEventListener('DOMContentLoaded', () => {
    remember();
    observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['aria-busy'] });
  });
}

This is a model: it applies ARIA's rules, but it knows nothing about a screen reader's speech queue, its settings or bugs between it and the browser. I ran four flows against Bookstore as it is now; the script is in the lab's a11y/ folder, with the mock API running on 127.0.0.1:52411 as in the earlier posts:

node announce.mjs book
node announce.mjs catalog
node announce.mjs checkout
node announce.mjs order
Enter on "Add to cart"
  (nothing announced)
  focus: button "Add to cart"
category: Science Fiction
  (nothing announced)
  focus: radio "Science Fiction"
submit: name empty, e-mail "ada"
  (nothing announced)
  focus: textbox "Full name" description="Enter your full name." invalid=true
submit: valid form, 4 x War and Peace (3 in stock)
  (nothing announced)
  focus: textbox "E-mail"
submit: valid order
  (nothing announced)
  focus: (nothing focused)

Nothing is announced in any flow. The book goes into the cart, but focus stays on the button and "Added." reaches only sighted users. The same goes for the result count when the category changes. On the invalid form, focus goes to the first field and its error is read (the previous post's work), but how many errors there are is unknown; when stock is short (409), focus stays in the e-mail field and the server's message sits silently below the form. After a successful order the form disappears, so focus is nowhere.

A Live Region Must Be in the DOM First

I applied the one-line fix from the guess and measured it:

page loaded
  (nothing announced)
  focus: (nothing focused)
Enter on "Add to cart"
  missed (region inserted with its text): status "Added. Go to cart"
  focus: button "Add to cart"
Enter again
  (nothing announced)
  focus: button "Add to cart"

The recorder marked the region as "missed". The reason is Angular's @if: when added() becomes true, the paragraph is added to the DOM in one go, together with role="status" and its text. Screen readers announce changes inside a live region; whether a new region that arrives with its text is read depends on the browser and screen reader pair. MDN's live region guide says the same: set up the region empty first, give it time to reach the tree, then change its content; the most reliable way is to have the region in the initial markup. The second line shows a second problem: when the button is pressed again, the text stays the same, so there is no change and no announcement.

This trap is common in Angular, because @if, @for and @switch create and destroy elements. The rule: put the live region outside the control flow block as an element that is always rendered, and let only the text inside it change.

The Catalog: Result Count and aria-busy

The "20 books" line in the catalog is a textbook status message. Before, during books.isLoading() another branch of the @if was rendered, so the line was destroyed and recreated on every load. We moved the line out of the @if, produced its text from a computed signal and gave the area around the list aria-busy:

<!-- aria-busy while a new result is loading; the status inside is read once the list is ready -->
<section class="results" [attr.aria-busy]="books.isLoading()">
  <!-- Always in the DOM: screen readers announce changes to its text, not a region that appears with its text (WCAG 4.1.3).
       aria-live repeats what role="status" already means, for older browser and screen reader pairs. -->
  <p class="muted" role="status" aria-live="polite" data-testid="book-count" [class.error]="books.error()">{{ resultText() }}</p>
  @if (books.isLoading()) {
    <p class="muted">Loading books…</p>
  } @else if (books.value(); as result) {
  // The text of the status message: what the list shows now, including the filters that produced it
  protected readonly resultText = computed(() => {
    if (this.books.error()) {
      return 'The catalog could not be loaded. Is Bookstore.Api running?';
    }
    const result = this.books.value();
    if (!result) {
      return '';
    }
    const category = this.categories.value().find(c => c.slug === this.category())?.name;
    return `${result.total} ${result.total === 1 ? 'book' : 'books'}` +
      (category ? ` in ${category}` : '') + (this.search() ? ` for "${this.search()}"` : '');
  });
page loaded
  live polite: "20 books"
  focus: (nothing focused)
category: Science Fiction
  live polite: "3 books in Science Fiction"
  focus: radio "Science Fiction"
search: tolkien
  live polite: "1 book for "tolkien""
  focus: searchbox "Search books"
search: asimov tolkien
  live polite: "0 books for "asimov tolkien""
  focus: searchbox "Search books"

The new text is read once when the page opens and then on every filter change. The reason we put the filter into the text is the second guess question: if another search after "1 book" again returns one result, the text does not change and nothing is announced; "1 book for "tolkien"" is different for every search. Zero results get a message too, because "nothing happened" and "no results" are the same silence for a screen reader user. The announcement on page load is a choice as well: the region is empty on the first render and fills when the list arrives. If you do not want it, find a way to keep the first value empty, but hearing the result at the top of the page is usually useful.

To see what happens on a slow network, I added a mode to the recorder that delays book list responses by one second with Playwright's page.route. The properties Chromium gives in the tree:

while loading:
  generic "" busy=1
  status "" live=polite atomic=true relevant=additions text
loaded:
  status "" live=polite atomic=true relevant=additions text
announcements
  live polite: "1 book in Fantasy"
  focus: radio "Fantasy"

While loading, the container has busy; the status node carries atomic=true although we never wrote aria-atomic, so the defaults of role="status" really end up in the tree. We added aria-live="polite" ourselves: the role already says this, but MDN recommends repeating it for compatibility with older browser and screen reader pairs (for role="alert" it advises the opposite, because it causes double speaking in VoiceOver on iOS). According to ARIA, a screen reader may hold back changes in an area with aria-busy="true" until it is done; our recorder behaves that way and reads one message when loading ends. Support for aria-busy in real screen readers is uneven, so the actual guarantee is in the code: the text is empty while loading and switches to its final value in one step when loading ends. "Loading books…" stays as a visible indicator but not inside the live region; hearing two messages ("Loading", then the result) on every filter change is unnecessary.

ariaNotify(): Announcements Without a Live Region

The book page is different: the text we want to announce ("Dune added to your cart. Cart: 2 items.") is not what the screen shows, and it must be announced on the second click too. Instead of keeping an invisible live region for this, we can use the new ariaNotify(). According to the web-features data, it shipped in Chrome and Edge 141 (September/October 2025), Firefox 150 (April 2026) and Safari 27, and became Baseline Newly available on 14 September 2026. According to MDN, document.ariaNotify(text, { priority }) puts the text in the screen reader's queue; "normal" (the default) roughly maps to polite and "high" to assertive; the voice is chosen from <html lang>; with two calls in a row some screen readers read only the last one, so related messages belong in one sentence. If a Permissions Policy blocks it, it silently does nothing, without even throwing an error.

There are two options: do everything with live regions, or prefer ariaNotify() for announcements whose text is not in the DOM and fall back to a live region in browsers without it. The first has the advantage of years of testing in every screen reader; the downside is that you have to keep an invisible region in the right place at the right time in every component. With the second you announce with one line and no DOM work, but the API is new: browser support was completed a year ago, and we could not measure screen reader behavior for it in this post. For Bookstore we chose the second and made Angular CDK's LiveAnnouncer the fallback:

import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Injectable, inject } from '@angular/core';

// TypeScript 6.0's lib.dom does not declare ariaNotify() yet (Baseline since September 2026)
declare global {
  interface Document {
    ariaNotify?(announcement: string, options?: { priority?: 'normal' | 'high' }): void;
  }
}

// Tells screen readers about a change without moving focus (WCAG 4.1.3).
@Injectable({ providedIn: 'root' })
export class Announcer {
  // The CDK adds its aria-live element to <body> as soon as it is created, so it is there before the first message
  private readonly liveAnnouncer = inject(LiveAnnouncer);

  announce(message: string, priority: 'normal' | 'high' = 'normal'): void {
    if (document.ariaNotify) {
      document.ariaNotify(message, { priority });
      return;
    }
    // Browsers without ariaNotify(): the same message through the CDK's live region
    void this.liveAnnouncer.announce(message, priority === 'high' ? 'assertive' : 'polite');
  }
}
  protected async addToCart(book: BookDetails, quantity: number): Promise<void> {
    await this.cartStore.add(book.isbn, quantity);
    this.added.set(true);
    // The visible "Added." stays the same on the second click; the announcement names the book and the new count
    const count = this.cartStore.itemCount();
    this.announcer.announce(`${book.title} added to your cart. Cart: ${count} ${count === 1 ? 'item' : 'items'}.`);
  }
page loaded
  (nothing announced)
  focus: (nothing focused)
Enter on "Add to cart"
  ariaNotify normal: "Dune added to your cart. Cart: 1 item."
  focus: button "Add to cart"
Enter again
  ariaNotify normal: "Dune added to your cart. Cart: 2 items."
  focus: button "Add to cart"

Two details. TypeScript 6.0's lib.dom does not have ariaNotify yet; we declared the method ourselves as optional, which also makes feature detection mandatory at the type level. We announce in the button's handler, not in an effect() that watches itemCount: the effect would also run when the cart is loaded from the API on page load and would read out a "Cart: 2 items" nobody asked for. An announcement belongs where the result of the user's action is produced.

To try the fallback, I ran the recorder with an init script that deletes ariaNotify (node announce.mjs book no-arianotify):

page loaded
  (nothing announced)
  focus: (nothing focused)
Enter on "Add to cart"
  live polite atomic: "Dune added to your cart. Cart: 1 item."
  focus: button "Add to cart"
Enter again
  live polite atomic: "Dune added to your cart. Cart: 2 items."
  focus: button "Add to cart"
.cdk-live-announcer-element on screen: 1x1 px

When the service is first injected, LiveAnnouncer adds a div with aria-live="polite" and aria-atomic="true" to the end of <body>; announce() first empties it and writes the new text about 100 ms later. That is why the same message is announced the second time too, and the region is ready in the DOM before the message. The 1x1 pixels on the last line come from the CDK's a11y CSS; before adding it to angular.json, the same measurement gave this:

.cdk-live-announcer-element on screen: 1264x18 px

The element was a full-width line at the very bottom of the page that showed the message to everyone. The style of the .cdk-visually-hidden class is in @angular/cdk/a11y-prebuilt.css; in projects that use Angular Material the theme already brings it, people who use the CDK on its own forget it:

            "styles": [
              "node_modules/@angular/cdk/a11y-prebuilt.css",
              "src/styles.css"
            ]

Form Errors: Error Summary and Focus

The order form has two kinds of errors: field errors the client finds and errors the API returns (400 validation, 409 not enough stock). Here too there are two ways. The first is to move focus to the first invalid field and announce the number of errors through a live region. The second is the error summary from the GOV.UK Design System: a box above the form titled "There is a problem" that links each error to its field; after submit, focus goes to this box. The first needs less markup, but server errors that cannot be tied to a field still need a place of their own. The second gives the user the whole picture first and then takes them to each field through links; because focus moves, no live region is needed either (the Understanding page says role="alert" is needed only when focus does not move). In Bookstore we chose the second; aria-invalid and aria-describedby next to the fields stay as in the previous post:

  @if (errorSummary().length) {
    <!-- Receives focus after a failed submit: the heading is its name, the list its description -->
    <section #summary class="error-summary" tabindex="-1" aria-labelledby="error-summary-title"
             aria-describedby="error-summary-list" data-testid="error-summary">
      <h2 id="error-summary-title">There is a problem</h2>
      <ul id="error-summary-list">
        @for (item of errorSummary(); track item.message) {
          <li>
            @if (item.fieldId) {
              <a [href]="'#' + item.fieldId" (click)="focusField($event, item.fieldId)">{{ item.message }}</a>
            } @else {
              {{ item.message }}
            }
          </li>
        }
      </ul>
    </section>
  }
  // One list for the summary: field errors first, in the order of the form, then the error from the API
  protected readonly errorSummary = computed<SummaryItem[]>(() => [
    ...(this.nameError() ? [{ message: this.nameError(), fieldId: 'customer-name' }] : []),
    ...(this.emailError() ? [{ message: this.emailError(), fieldId: 'customer-email' }] : []),
    ...(this.serverError() ? [{ message: this.serverError(), fieldId: null }] : []),
  ]);
    if (this.errorSummary().length) {
      this.focusAfterRender(this.summary);
      return;
    }
    try {
      this.placedOrder.set(await this.store.placeOrder(nameBox.value, emailBox.value));
      this.focusAfterRender(this.confirmation);
    } catch (error) {
      this.serverError.set(this.describe(error));
      this.focusAfterRender(this.summary);
    }
  }

  // With <base href="/en/">, href="#customer-name" would leave the cart; move focus to the field ourselves.
  protected focusField(event: Event, fieldId: string): void {
    event.preventDefault();
    this.host.nativeElement.querySelector<HTMLElement>(`#${fieldId}`)?.focus();
  }

  // Focus after the next render, when the element exists and its text is complete
  private focusAfterRender(target: () => ElementRef<HTMLElement> | undefined): void {
    afterNextRender(() => target()?.nativeElement.focus(), { injector: this.injector });
  }

  // The API answers with ProblemDetails: 400 validation errors or 409 when stock is not enough.
  private describe(error: unknown): string {
    const problem = error instanceof HttpErrorResponse ? error.error : null;
    if (error instanceof HttpErrorResponse && error.status === 409) {
      // The API lists the books by ISBN; the user knows them by title
      const titles = this.store.cart()?.items.filter(line => problem?.detail?.includes(line.isbn)).map(line => line.title) ?? [];
      if (titles.length) {
        return `Not enough stock for ${titles.join(', ')}. Lower the quantity and try again.`;
      }
    }
    return problem?.detail ?? problem?.title ?? 'The order could not be placed. Try again in a few minutes.';
  }
submit: name empty, e-mail "ada"
  (nothing announced)
  focus: region "There is a problem" description="Enter your full name. Enter an e-mail address like name@example.com."
submit: valid form, 4 x War and Peace (3 in stock)
  (nothing announced)
  focus: region "There is a problem" description="Not enough stock for War and Peace. Lower the quantity and try again."

The focused box is now a region: its name comes from the heading, its description from the list. When it receives focus, a screen reader has what it needs to read the name, the role and the description; how many errors there are and what to do is there in one go. Tab goes to the first link in the summary, and Enter from there to the field. The links have a trap familiar from earlier posts: because of <base href="/en/">, href="#customer-name" would leave the cart for /#customer-name; as with the skip link, we move focus ourselves.

The 409 message changed as well. The API returns the stock problem as a list of ISBNs: "Not enough stock for: 9781400079988." Technically that text reports an error, but it does not say what to fix and how, as 3.3.3 Error Suggestion expects. We look up the book title from the cart lines and add "Lower the quantity and try again". GOV.UK also recommends prefixing the page title with "Error: "; Bookstore does not do that yet, it is in A Small Experiment.

On a successful order, the form and the "Place order" button disappeared and focus fell back to <body>. We made the confirmation line an h2 with tabindex="-1" and give it focus after rendering:

@if (placedOrder(); as order) {
  <!-- Takes focus after the order: the form and its button are gone, so focus would otherwise fall back to <body> -->
  <h2 #confirmation tabindex="-1" class="success" data-testid="order-confirmation">
    Order #{{ order.id }} placed ({{ order.status }}). Total: {{ order.total | currency: 'TRY' : 'symbol-narrow' }}
  </h2>
  <a routerLink="/">Continue shopping</a>
submit: valid order
  (nothing announced)
  focus: heading "Order #2 placed (Placed). Total: ₺279.00"

With these fixes, the two open lines on the accessibility statement page (unannounced messages and server errors, 3.3.1 and 4.1.3) are gone; with an empty list it says "No known limitations at the moment". node scan.mjs still reports zero WCAG violations on the four pages; just remember that axe did not find any of this post's problems in their original form either, because the timing of a live region is something a static scan cannot see.

Toasts and WCAG's Timing Criteria

Many applications show messages like "Added to cart" as a toast that disappears after a few seconds. Two criteria come in here. 2.2.1 Timing Adjustable (A) requires that time limits set by the content can be turned off, adjusted or extended. The example on the Understanding page is exactly a toast: an e-mail client that announces new mail with a five-second toast is not counted as a time limit, because the user can get the same information from the Inbox; if there is no other way to the information, the message must meet the criterion. The second is 4.1.3: if the toast's text does not go through a live region, a screen reader user never hears the message, and even if they do, they cannot go back and read it after the toast is gone.

Practical rules: make sure the information in the toast is somewhere else too (the cart count updates in the header); if the toast contains an action (for example "Undo"), do not let it disappear on a timer, or wait until it has had focus; do not close it while the mouse is over it or focus is inside it; announce its text separately through a live region or ariaNotify(). In Bookstore the visible "Added." message does not disappear; the announcement goes through a separate channel. The third parameter of the CDK's announce(), duration, only clears the region and does not remove anything visible.

In Production

Keep announcements few and meaningful. A "filter as you type" box that announces the result count on every keystroke keeps the user from hearing what they type. In such cases update the text with a short debounce or announce only on Enter; Bookstore's search already works on submit.

Keep the language of an announcement the same as the page. ariaNotify() picks the voice from <html lang>; a Turkish announcement on an English page is read with the wrong pronunciation. In a multilingual application the messages must go through translation too.

Save assertive for things the user must know right away. A session about to expire can be assertive; "Saved" is fine as polite.

Check the text of announcements in tests too. You can do what the recorder does in Playwright tests with a role="status" locator and an ariaNotify spy; how to wire these tests into CI is the topic of the last post in the series.

Common Pitfalls

First, adding a live region to the DOM together with its text. A role="status" inside an @if is often not read; the region must be there first.

Second, writing the same text again and expecting it to be announced. If the text does not change, there is no change; either make the text different or, like the CDK, clear it first and then write it.

Third, making everything role="alert". Assertive messages interrupt the current speech; used often, they make the page impossible to follow.

Fourth, using the CDK's LiveAnnouncer without its a11y CSS. The message becomes visible to everyone at the bottom of the page.

Fifth, leaving focus to itself after a successful or failed submit. If the clicked button is gone, focus falls back to <body> and the user starts again from the top of the page.

Sixth, tying an announcement to an effect(). A signal changes not only through the user's action but also through loading and refreshing; the announcement belongs where the user's action is.

A Small Experiment

★☆☆ Remove the filter part from resultText in the catalog and run node announce.mjs catalog. What is announced between two searches that return the same number of results?

★★☆ Prefix the page title with "Error: " when the error summary appears and remove it when the errors are gone. Use the Title service and the errorSummary signal; add page.title() to the output of announce.mjs checkout and measure.

★★★ On the cart page, announce the line total and the cart total when a quantity changes ("War and Peace: 2 copies. Cart total: ₺1,378.00." or similar). Is one announcement per arrow key press better, or a single one when the change is done? Measure the difference between the change and input events with the recorder.

In this post we measured that announcements are written correctly into the tree and into live regions, but we did not hear when, in what order and how a screen reader reads them. In the next post we look at the core commands of NVDA, VoiceOver and TalkBack, the difference between browse mode and focus mode, and comparing the accessibility tree with screen reader output.