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"oraria-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.
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
- 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.
- 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. - Key related messages. Messages about the same thing (
"username-check") replace each other while unsaid, and repeats of the last spoken text are dropped. - 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.
- Send urgent messages immediately. A failed submit’s summary, a session timeout warning, a payment failure:
alertpriority bypasses the wait. - 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.
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.
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.