A form that announces every validation change through a live region turns typing into noise for screen-reader users: “Enter a valid email” after the first character, “Checking availability” at each pause, “Username available”, “2 problems remain”, “1 problem remains” — each interrupting the echo of what they just typed, some dropped entirely because the next one arrived too soon.

Live regions are a shared, low-bandwidth channel, and the fix is to treat them that way: one announcer per page, which coalesces rapid messages, drops duplicates, waits for a pause in typing before speaking non-urgent updates, and lets urgent messages through. This page builds that announcer as part of ARIA live regions for form errors.


Context and prerequisites

How screen readers handle live regions, in practice:

  • A polite region (role="status" or aria-live="polite") queues its announcement until the user is idle; an assertive region (role="alert") may interrupt the current speech.
  • If the same region changes again before the previous text is spoken, many screen readers speak only the latest text — or, in some combinations, both. Behaviour varies across NVDA, JAWS, VoiceOver and TalkBack.
  • Setting a region’s text to the same string again usually produces no announcement, because nothing changed.
  • Keystroke echo (the screen reader speaking typed characters) competes with polite announcements; frequent updates while typing are the main source of noise.

So the announcer’s job is to decide what is worth saying, when, and to make each message a distinct change the screen reader will notice.

Announcements during three seconds of typing Without throttling, validation and status changes produce six announcements within three seconds, interleaved with keystroke echo, and some are dropped or cut off. With the coalescing announcer, non-urgent updates are held until the user has paused typing for 700 milliseconds, duplicates are dropped, and only the latest status is spoken, producing two announcements in the same period. typing email username unthrottled 6 messages coalesced 1 2 0ms 500ms 1000ms 1500ms 2000ms 2500ms 3000ms

The core pattern: a single page-level announcer

type Priority = "status" | "alert";

interface Pending { text: string; priority: Priority; key?: string }

export class Announcer {
  private politeEl: HTMLElement;
  private alertEl: HTMLElement;
  private queue: Pending[] = [];
  private lastSpoken = new Map<string, string>();   // key → last text, to drop duplicates
  private timer: ReturnType<typeof setTimeout> | undefined;
  private lastKeyAt = 0;

  constructor(root: HTMLElement = document.body, private quietMs = 700) {
    // Regions exist from page load, empty, so assistive technology registers them.
    this.politeEl = Object.assign(document.createElement("div"), { className: "visually-hidden" });
    this.politeEl.setAttribute("role", "status");
    this.alertEl = Object.assign(document.createElement("div"), { className: "visually-hidden" });
    this.alertEl.setAttribute("role", "alert");
    root.append(this.politeEl, this.alertEl);
    root.addEventListener("keydown", () => { this.lastKeyAt = Date.now(); }, true);
  }

  /** key groups messages about the same thing (e.g. "username-check"): only the latest survives. */
  say(text: string, priority: Priority = "status", key?: string) {
    if (key && this.lastSpoken.get(key) === text) return;          // same news again: stay quiet
    if (key) this.queue = this.queue.filter((p) => p.key !== key); // newer replaces older, unsaid
    this.queue.push({ text, priority, key });
    if (priority === "alert") return this.flush();                  // urgent: no waiting
    this.schedule();
  }

  private schedule() {
    clearTimeout(this.timer);
    const sinceKey = Date.now() - this.lastKeyAt;
    const wait = Math.max(0, this.quietMs - sinceKey);              // wait for a pause in typing
    this.timer = setTimeout(() => this.flush(), wait || 50);
  }

  private flush() {
    clearTimeout(this.timer);
    if (!this.queue.length) return;
    const alerts = this.queue.filter((p) => p.priority === "alert");
    const statuses = this.queue.filter((p) => p.priority === "status");
    this.queue = [];
    if (alerts.length) this.write(this.alertEl, alerts.map((p) => p.text).join(" "));
    // Coalesce statuses into one sentence rather than several rapid changes.
    if (statuses.length) this.write(this.politeEl, statuses.map((p) => p.text).join(" "));
    for (const p of [...alerts, ...statuses]) if (p.key) this.lastSpoken.set(p.key, p.text);
  }

  private write(el: HTMLElement, text: string) {
    // Clear, then set on the next frame: guarantees a DOM change even if the
    // text equals what the region held before.
    el.textContent = "";
    requestAnimationFrame(() => {
      el.textContent = text;
      // Clear afterwards so stale text is not re-read when users navigate into it.
      setTimeout(() => { if (el.textContent === text) el.textContent = ""; }, 5000);
    });
  }
}

export const announcer = new Announcer();

Step-by-step walkthrough

  1. Create one announcer per page, with regions present from load. Regions added at the same moment as their text are often ignored; render them empty at startup.
  2. Route every form announcement through it. Field components call announcer.say(...) instead of owning live regions, so the page has one queue and one policy.
  3. Key related messages. Messages about the same thing ("username-check") replace each other while unsaid, and repeats of the last spoken text are dropped.
  4. Wait for a pause in typing before polite messages. 600–800 ms after the last keystroke keeps announcements from colliding with key echo — the same principle as the delayed indicator in accessible pending state for async validation.
  5. Send urgent messages immediately. A failed submit’s summary, a session timeout warning, a payment failure: alert priority bypasses the wait.
  6. Clear regions after speaking. Stale text left in a live region is re-read when users browse into it, out of context.

Why fewer announcements are more accessible

It can feel as though announcing everything is the most accessible choice, because nothing is hidden. In practice, a stream of announcements forces screen-reader users to listen to every status change at the speed the form produces them, and interrupts the feedback they rely on while typing. Sighted users glance at status text when they want it; the equivalent for screen-reader users is status text linked with aria-describedby, read when they focus the field, plus a small number of well-timed announcements for things they need to know without asking. The announcer’s job is to choose those few moments well.

What to announce, and how Validation errors appearing on blur are announced politely, keyed by field. Errors clearing are not announced; the change in aria-invalid is enough. Async check results are announced politely, keyed by the check. Checking in progress is not announced. A failed submit is announced as an alert, or handled by moving focus to the summary. A successful save is announced politely. Row added or removed in a repeatable group is announced politely. Event Announce? Priority Key error appears (blur) yes status field name error clears no — — async result yes status check id "checking…" no — — submit failed yes / focus alert form saved yes status form row added / removed yes status group

Failure modes and edge cases

1. Multiple competing regions

Each component with its own live region means several regions changing at once; screen readers handle simultaneous updates unpredictably. A single announcer avoids the contention.

2. Announcing focus moves

If submit moves focus to an error summary, the summary is read on focus. Announcing the same content through a live region as well produces a double read. Choose one mechanism per event, per building an accessible error summary.

3. aria-atomic and partial reads

Updating part of a region’s content can lead to only the changed part being read. Writing the full sentence into an empty region each time avoids depending on aria-atomic support.

4. Messages that are too long

Long announcements get cut off by the next one. Keep them to a sentence; details belong in the field’s description, read on focus.

5. Testing

The announcer’s queue logic is pure enough to unit test with fake timers. What it sounds like needs real screen readers — see the screen-reader testing matrix for form errors.

One message through the announcer A component calls say with the text username available and the key username-check. The announcer drops it if the last spoken text for that key was identical. Otherwise it removes any unsaid message with the same key from the queue and adds the new one. It waits until the user has not pressed a key for 700 milliseconds. It then coalesces all queued status messages into one sentence, clears the polite region, writes the sentence on the next frame, and clears it again after a few seconds. say("ada_l is available", status, "username-check") Called by the field. No component owns a live region. Duplicate? Replace? Same as last spoken → drop. Same key unsaid → replace. Only the latest news survives. Wait for a typing pause 700 ms since last keydown. Alerts skip this wait. Coalesce and write One sentence, clear-then-set. Cleared again after a few seconds.

Verification checklist


Frequently Asked Questions

How long should the typing pause be?

600–800 ms works well for most users: long enough that the user has paused, short enough that the message still relates to what they just did. Make it a single constant so it can be tuned after testing with users.

Is aria-live="polite" the same as role="status"?

role="status" implies aria-live="polite" and aria-atomic="true", and adds status semantics. Either works for polite announcements; role="status" is the more descriptive choice.

Should every field error be announced?

Announce errors when they first appear after the user leaves a field, keyed by field so rapid changes collapse. On submit, prefer moving focus to the error summary instead of announcing each error, which would produce a long, interruptible stream.


Related

← ARIA Live Regions for Form Errors