Toasts and Inline Feedback in Popups
Give users feedback inside a small extension popup: inline status next to controls, toasts with undo, accessible live regions, errors that explain what to do, feedback for actions that finish after the popup closes, and timing.
Table of Contents
The user clicks “Archive” in the popup and the item disappears. Did it archive, or delete? Can they undo it? They click “Sync now” and nothing visible happens for two seconds, so they click again. They click “Save” on a page the extension cannot read and the button just stops responding. A popup is a few hundred pixels square and closes the moment focus leaves it, so feedback has to be compact, immediate and accessible — and actions that finish after the popup closes need feedback somewhere else entirely. This guide covers inline status, toasts with undo, error messages, live regions and the popup-closed case. It belongs to popup interface design.
Choosing a feedback form
Feedback should appear where the user is looking and last as long as it is useful. Inline status — a short message or icon change next to the control — suits actions on a single control: “Saved”, a spinner inside a button, a checkmark. Toasts — a small bar at the bottom of the popup — suit actions that change the list or view, especially when an Undo makes sense. Inline errors sit next to the thing that failed and say what to do. Out-of-popup feedback — badge, notification — covers work that completes after the popup has closed. Whatever the form, screen reader users must hear it, which means an ARIA live region.
Step-by-step: feedback that works in a popup
1. Add one live region
1<!-- popup.html -->
2<div id="announcer" class="visually-hidden" role="status" aria-live="polite"></div>
3<div id="toast-host" aria-live="off"></div>
1export function announce(text) {
2 const el = document.querySelector("#announcer");
3 el.textContent = "";
4 requestAnimationFrame(() => (el.textContent = text));
5}
Execution context: the popup. A single polite live region, present from page load, is announced reliably by screen readers; regions created at the moment of the message often are not. Clearing then setting on the next frame makes repeated identical messages (“Saved”, “Saved”) announce each time. The visual toast host is not itself live — the announcer speaks for it — to avoid double announcements. See announcing dynamic updates to screen readers.
2. Show progress inside the button
1async function withButtonState(button, work) {
2 const label = button.textContent;
3 button.disabled = true;
4 button.setAttribute("aria-busy", "true");
5 button.dataset.state = "busy";
6 try {
7 await work();
8 button.dataset.state = "done";
9 button.textContent = chrome.i18n.getMessage("done");
10 announce(chrome.i18n.getMessage("syncDone"));
11 } catch (e) {
12 button.dataset.state = "error";
13 showInlineError(button, e);
14 } finally {
15 setTimeout(() => { button.textContent = label; button.disabled = false; button.removeAttribute("aria-busy"); delete button.dataset.state; }, 1500);
16 }
17}
18syncBtn.addEventListener("click", () => withButtonState(syncBtn, () => chrome.runtime.sendMessage({ type: "sync-now" })));
Execution context: the popup. Disabling the button while busy prevents double submission; a spinner via [data-state="busy"] CSS shows something is happening. Showing “Done” briefly in the button itself is the most local feedback possible. Respect reduced motion for the spinner, as in respecting reduced motion and forced colors.
3. Build a toast with undo
1let current;
2export function toast(message, { actionLabel, onAction, ms = 5000 } = {}) {
3 current?.dismiss();
4 const el = document.createElement("div");
5 el.className = "toast";
6 el.append(Object.assign(document.createElement("span"), { textContent: message }));
7 let timer;
8 const dismiss = () => { clearTimeout(timer); el.remove(); current = null; };
9 if (actionLabel) {
10 const b = Object.assign(document.createElement("button"), { textContent: actionLabel });
11 b.addEventListener("click", () => { onAction(); dismiss(); });
12 el.append(b);
13 }
14 el.addEventListener("pointerenter", () => clearTimeout(timer));
15 el.addEventListener("pointerleave", () => (timer = setTimeout(dismiss, ms)));
16 el.addEventListener("focusin", () => clearTimeout(timer));
17 document.querySelector("#toast-host").append(el);
18 timer = setTimeout(dismiss, ms);
19 announce(actionLabel ? `${message}. ${actionLabel} available.` : message);
20 return (current = { dismiss });
21}
Execution context: the popup. One toast at a time keeps the small space readable. The timer pauses while the pointer or focus is on the toast, so users who reach for Undo don’t lose it. Keep toasts above the popup’s bottom edge and out of the way of the list’s last item.
4. Commit deferred actions safely
1const pending = new Map();
2
3function archiveWithUndo(item) {
4 removeFromList(item.id);
5 const commit = () => { pending.delete(item.id); chrome.runtime.sendMessage({ type: "archive", id: item.id }); };
6 const t = setTimeout(commit, 5000);
7 pending.set(item.id, { t, commit });
8 toast(chrome.i18n.getMessage("archived"), {
9 actionLabel: chrome.i18n.getMessage("undo"),
10 onAction: () => { clearTimeout(t); pending.delete(item.id); restoreToList(item); announce(chrome.i18n.getMessage("restored")); },
11 });
12}
13
14addEventListener("pagehide", () => { for (const { t, commit } of pending.values()) { clearTimeout(t); commit(); } });
Execution context: the popup. Committing after the undo window is the simplest undo model, but the popup can close at any moment. pagehide fires when it does, and runtime.sendMessage dispatched there still reaches the service worker. Alternatively, commit immediately and implement Undo as a reverse operation — safer for important data, since a crash loses nothing.
5. Write errors that say what to do
1function showInlineError(anchor, err) {
2 const map = {
3 "restricted-page": "errRestrictedPage", // "Readable can't run on browser pages. Try it on an article."
4 "no-site-access": "errNoSiteAccess", // "Readable doesn't have access to this site."
5 "offline": "errOffline", // "You're offline. We'll sync when you're back."
6 };
7 const el = anchor.closest(".field, .row, section").querySelector(".error") ?? anchor.insertAdjacentElement("afterend", Object.assign(document.createElement("p"), { className: "error" }));
8 el.textContent = chrome.i18n.getMessage(map[err?.code] ?? "errGeneric");
9 el.id ||= `err-${crypto.randomUUID()}`;
10 anchor.setAttribute("aria-describedby", el.id);
11 announce(el.textContent);
12}
Execution context: the popup. Map error codes from the worker to messages that explain the cause in plain words and the next step. Link the error to its control with aria-describedby. For fixable errors, add the fix inline — a “Grant access” button for missing site access.
6. Hand off feedback when the popup closes
For actions that take longer than a second or two, acknowledge the start in the popup (“Export started”) and deliver completion through the badge or a notification from the service worker. See progress notifications for long tasks and running work that outlives the popup.
7. Keep timing humane
Inline confirmations: about 1.5–2 seconds. Toasts with actions: at least 5 seconds, paused on hover and focus. Errors: until fixed or dismissed. Never auto-dismiss something the user must act on.
Common mistakes
- No feedback for silent actions. Users repeat them.
- Live regions created on demand. Screen readers miss them.
- Toasts that vanish under the cursor. Pause on hover and focus.
- Undo lost when the popup closes. Commit on
pagehide. - Generic “Something went wrong”. Say why and what to do.
Cross-browser variation
- Chrome / Edge:
pagehidefires when the popup closes; messages sent there reach the worker. - Firefox: popups behave similarly; test that
pagehidework completes. - Safari: popovers close on blur too; keep deferred commits short or commit immediately.
Verification
- Archive an item, press Undo within five seconds, and confirm it returns and nothing was archived.
- Archive and close the popup immediately; confirm the archive was committed.
- With a screen reader, confirm “Saved”, toasts and errors are announced once each.
- Trigger each error code and confirm the message names the cause and fix.
FAQ
Should toasts stack?
Not in a popup. Replace the current toast; the space is too small for stacks.
Is a spinner enough for slow actions?
For up to a few seconds. Longer actions need progress or a handoff to the badge.
Should success always be announced?
Announce results the user cannot see change; skip announcing changes that move focus, which screen readers already report.
Where should toasts sit in a popup with a scrolling list?
Fix them to the bottom of the popup viewport, above the list, with enough padding that they never cover the last item’s controls. Popups do not resize to make room, so reserve the space with bottom padding on the list while a toast is visible.
Related
- Popup loading and empty states — other popup states.
- Announcing dynamic updates to screen readers — live regions.
- Badge text, colour and count patterns — feedback after close.
- Popup interface design — the parent topic.