Announcing Dynamic Updates to Screen Readers
Live regions in MV3 surfaces that are destroyed and recreated: polite versus assertive, why an injected region never announces, and how to report background worker events.
Table of Contents
- Root cause: assistive technology observes a region, it does not scan the page
- Step 1. Ship the regions in the markup
- Step 2. Announce through one function
- Step 3. Choose politeness by whether the user must stop
- Step 4. Route worker events into a surface that exists
- Step 5. Announce structural changes, not just text
- Cross-browser variation
- Verification
- FAQ
- Related
The sync finishes, the badge turns green, and a screen-reader user hears nothing. You add role="status" to a paragraph you create at that moment and still hear nothing, because a live region has to exist before the change it announces. Extension surfaces make this worse than usual: a popup is created and destroyed constantly, so “before” is a shorter window than on a web page, and half the events worth announcing happen in a service worker with no DOM at all. This page is part of Internationalization & Accessibility and covers announcements that actually reach the user.
Root cause: assistive technology observes a region, it does not scan the page
A screen reader watches nodes that were marked as live when the accessibility tree was built. Inserting a fully formed <div role="alert">Done</div> gives it nothing to observe — by the time it notices the node, the “change” already happened. The region must be in the document first, empty, and then have its text changed.
Step 1. Ship the regions in the markup
Two regions cover almost everything: one polite for progress and confirmations, one assertive for errors that block the user.
1<!-- popup.html — both empty, both present from the first paint -->
2<p id="status" role="status" aria-live="polite" class="visually-hidden"></p>
3<p id="alert" role="alert" aria-live="assertive" class="visually-hidden"></p>
1/* Visually hidden but still announced — never use display:none or visibility:hidden here. */
2.visually-hidden {
3 position: absolute;
4 inline-size: 1px; block-size: 1px;
5 margin: -1px; padding: 0; border: 0;
6 clip-path: inset(50%);
7 overflow: hidden;
8 white-space: nowrap;
9}
Execution context: Popup, options page or side panel markup and its stylesheet. display: none and visibility: hidden remove the node from the accessibility tree entirely, so a hidden region styled that way announces nothing — the clip technique keeps it in the tree while taking no visual space. role="status" implies aria-live="polite" and role="alert" implies assertive, but stating both is harmless and makes the intent readable. All three engines and every major screen reader honour this pattern.
Step 2. Announce through one function
Scattered textContent assignments produce announcements that stack, repeat and interrupt each other.
1// announce.js
2let lastMessage = "";
3
4export function announce(message, { assertive = false } = {}) {
5 const region = document.getElementById(assertive ? "alert" : "status");
6 if (!region) return;
7
8 // An identical string is not a change, so nothing would be announced. Nudge it.
9 const text = message === lastMessage ? `${message} ` : message;
10 lastMessage = message;
11
12 region.textContent = "";
13 requestAnimationFrame(() => { region.textContent = text; });
14}
Execution context: Popup or options document. The clear-then-set across an animation frame is what makes rapid consecutive announcements reliable: setting the text twice in the same frame is coalesced into one mutation and the second message is lost. The non-breaking space appended to a repeated message makes it a genuine change without being audible. Behaviour is consistent across Chrome, Firefox and Safari here because the work is done by the screen reader, not the browser’s extension layer.
Step 3. Choose politeness by whether the user must stop
Assertive interrupts whatever is being read. Use it when continuing would waste the user’s time, and not otherwise.
1// popup.js
2import { announce } from "./announce.js";
3import { t } from "./i18n.js";
4
5async function onSyncComplete({ changed }) {
6 announce(t("syncDone", [String(changed)])); // polite
7}
8
9async function onQuotaExceeded() {
10 announce(t("quotaExceededShort"), { assertive: true }); // interrupts
11 document.getElementById("save").setAttribute("aria-disabled", "true");
12}
Execution context: Popup document, driven by messages from the worker. The announcement text comes from the same message bundle as the visible strings — an English announcement in an otherwise translated interface is a common and jarring oversight, and the bundle pattern is covered in localising extension UI with the i18n API. Setting aria-disabled rather than the disabled property keeps the control focusable so the user can reach it and hear why it is unavailable.
Step 4. Route worker events into a surface that exists
The service worker has no DOM, so it cannot announce anything. What it can do is record state that an open surface reflects.
1// sw.js — the worker records, it does not announce
2async function setActivity(kind, detail) {
3 await chrome.storage.session.set({ activity: { kind, detail, at: Date.now() } });
4 chrome.runtime.sendMessage({ type: "ACTIVITY", kind, detail }).catch(() => {
5 // No surface is open. That is fine — the popup reads the stored value when it opens.
6 });
7}
1// popup.js — announce on open, then on every change
2chrome.runtime.onMessage.addListener((msg) => {
3 if (msg?.type === "ACTIVITY") announce(describe(msg), { assertive: msg.kind === "error" });
4});
5
6const { activity } = await chrome.storage.session.get("activity");
7if (activity && Date.now() - activity.at < 30_000) announce(describe(activity));
Execution context: Service worker and popup respectively. The catch on sendMessage is required rather than optional: with no listener registered — no popup open — the call rejects with “Receiving end does not exist” in Chrome and Firefox, and an unhandled rejection in the worker is noisy at best. Reading the stored activity on open covers the common case where the work finished while the popup was closed; the thirty-second window stops a stale message being announced hours later. Safari’s more aggressive worker reclamation makes the stored value the primary path rather than the fallback.
Step 5. Announce structural changes, not just text
When a list re-renders, a screen reader user needs to know how many results there are now — the visual user can see it at a glance.
1// popup-list.js
2export function renderResults(items, query) {
3 list.replaceChildren(...items.map(row));
4 list.setAttribute("aria-busy", "false");
5
6 announce(items.length === 0
7 ? t("resultsNone", [query])
8 : t("resultsCount", [String(items.length)]));
9}
10
11export function beginSearch() {
12 list.setAttribute("aria-busy", "true"); // suppresses announcements while it churns
13}
Execution context: Popup or side panel document. aria-busy="true" tells assistive technology to hold off until the region settles, which prevents a per-keystroke filter from producing an announcement per keystroke. Pair it with a debounce on the input so the count is announced once the user pauses, not on every character — the same debounce that protects storage writes in syncing options form state with chrome storage.
Cross-browser variation
- Chrome/Edge: live regions in a popup are announced normally by NVDA and JAWS on Windows. Because the popup closes when focus leaves it, a long assertive message can be cut off mid-sentence — keep announcements short.
- Firefox: the same, with the additional case of an options page embedded in the add-ons manager, where your document is in an iframe; live regions still work, but the surrounding manager may also be announcing.
- Safari: VoiceOver announces both region types reliably, but Safari can dismiss the popup on app switch, ending speech abruptly. Duplicate anything important into a persistent surface such as the options page.
- All engines: a content script’s live region belongs to the host page’s accessibility tree and competes with the page’s own announcements. Announce only what the user’s action caused, never background activity.
Verification
- Turn on a screen reader — NVDA on Windows, VoiceOver on macOS — and open the popup. Trigger a sync and confirm the completion is spoken.
- Trigger two announcements within a second. Both should be heard, in order.
- Announce the same message twice in a row and confirm it is spoken both times.
- Type quickly in the filter field. There should be one count announcement after you stop, not one per keystroke.
- Trigger an error while a long polite message is being read and confirm the assertive message interrupts.
- Close the popup, complete a background sync, reopen, and confirm the result is announced once.
FAQ
Can I announce something from the service worker directly?
No. It has no DOM and no accessibility tree. Record the state and let an open surface speak it, or use a system notification, which the operating system announces itself.
Should every UI change be announced?
No. Decorative changes, hover states and spinners should be aria-hidden. Over-announcing is as unusable as silence.
Why does my announcement work in a test page but not in the popup?
Almost always because the popup rebuilt its DOM and the region was recreated with its text already set. Regions must be static in the markup.
Does aria-live work inside a shadow root?
Yes, provided the region is in the tree before the change. For injected UI that is the same rule, applied inside your shadow host.
Related
- Making popups and options keyboard navigable — the interactions these announcements describe.
- Localising extension UI with the i18n API — announcements come from the same bundle as the visible text.
- Updating the toolbar badge from the service worker — the visual channel this complements.
- Internationalization & Accessibility — the guide this page belongs to.