Popup Loading and Empty States
Design the popup states users see most often but designers draw least — first-run, empty, loading, offline, signed-out and error — so each one says what is happening and what to do next.
Table of Contents
Designs are drawn with a full list of data, and users meet the popup in every other condition: the first time after install with nothing saved, on a site where the extension does not apply, while signed out, with the network down, in the second before data arrives. Those states are where users decide whether the extension works. An empty white rectangle and an endless spinner both read as “broken”; a sentence and a button read as “ready”. This guide is part of popup interface design.
The states a popup must handle
Step-by-step
1. Model the state explicitly
Deriving the view from scattered flags produces impossible combinations — a spinner over an error, an empty state while signed out. One discriminated state value makes the combinations impossible.
1function popupState({ installFresh, signedIn, online, loading, error, items, applicable }) {
2 if (installFresh) return { kind: "first-run" };
3 if (!signedIn) return { kind: "signed-out" };
4 if (!applicable) return { kind: "not-applicable" };
5 if (error) return { kind: "error", error };
6 if (loading && !items) return { kind: "loading" };
7 if (!online && items) return { kind: "offline", items };
8 if (!items?.length) return { kind: "empty" };
9 return { kind: "list", items };
10}
Execution context: the popup document, or a shared module so the options page can reuse it. The order of the checks is the design: it encodes which state wins when several are true. Offline with cached items shows the items with a note, rather than an error.
2. Write a render per state, each with a sentence and an action
1const VIEWS = {
2 "first-run": () => view(
3 "Save articles to read later",
4 "Click Save on any page, or press Alt+Shift+S.",
5 button("Save this page", saveCurrent)),
6 "empty": () => view(
7 "Nothing saved yet",
8 "Pages you save appear here, on every device you sign in on.",
9 button("Save this page", saveCurrent)),
10 "not-applicable": () => view(
11 "Reader can't save this page",
12 "Browser pages and the extension store can't be saved."),
13 "signed-out": () => view(
14 "Sign in to see your list",
15 "Your saved pages sync across devices once you sign in.",
16 button("Sign in", signIn)),
17 "error": ({ error }) => view(
18 "Couldn't load your list",
19 friendly(error),
20 button("Try again", reload)),
21};
Execution context: the popup. Each view names what is happening and offers at most one primary action — the popup is open for a few seconds and cannot host a decision tree. Text is built with textContent inside view(), keeping error messages from the network out of the markup path.
3. Delay the loading state
Most loads in a well-built popup finish in tens of milliseconds, and a spinner that appears for one frame is a flicker. Show loading only if it is still loading after a short delay.
1let loadingTimer = setTimeout(() => render({ kind: "loading" }), 150);
2
3const items = await loadItems();
4clearTimeout(loadingTimer);
5render(popupState({ ...context, items }));
Execution context: the popup. With a render-from-storage first paint, as described in loading popup data without a flash of empty UI, the loading state should almost never appear — and when it does, it means something genuinely slow is happening.
4. Make the loading state honest and short
1<div class="loading" role="status" aria-live="polite">
2 <div class="skeleton" aria-hidden="true"></div>
3 <div class="skeleton" aria-hidden="true"></div>
4 <span class="visually-hidden">Loading your list…</span>
5</div>
Execution context: the popup document. Skeleton rows shaped like the real rows are calmer than a spinner and reserve the right space so nothing jumps when data arrives. The hidden text makes the state audible; aria-hidden keeps the decorative skeletons out of the accessibility tree.
5. Turn errors into sentences
1function friendly(err) {
2 if (!navigator.onLine) return "You're offline. Showing what's saved on this device.";
3 if (err?.status === 401) return "Your session expired. Sign in again to continue.";
4 if (err?.status >= 500) return "Our server is having trouble. Your saved pages are safe.";
5 return "Something went wrong. Try again in a moment.";
6}
Execution context: the popup. Status codes and stack traces are for the error report, not the user. The last line is a fallback, not a design — if it appears often, a case is missing above it. Reporting the technical detail separately is covered in capturing uncaught errors in every context.
The first-run state deserves the most care
The first time a user opens the popup is the only time they are actively trying to understand the extension. It happens once, and it decides a large share of retention.
Three things make it work. It says what the extension does in one sentence, in terms of the user’s goal rather than its features. It shows the one action that delivers the first bit of value, on the page the user is already on — not a tour, not a settings form. And it disappears for good once that action is taken, replaced by the ordinary empty or list state.
1async function markFirstRunDone() {
2 await chrome.storage.local.set({ firstRunDone: true });
3}
4// called after the first successful save
Execution context: the popup or the worker, after the user’s first meaningful action. Keying the first-run state on “has done the thing” rather than “has opened the popup” means a user who opens it, gets distracted and closes it still sees the guidance next time. A longer onboarding flow belongs on a dedicated page, as described in showing a first-run setup page after install.
Cross-browser variation
- Chrome / Edge: the popup is recreated on every open, so states are recomputed each time — there is no stale view to worry about.
navigator.onLineis reliable enough for the offline hint. - Firefox: identical popup lifecycle. Firefox’s popup can briefly render at a minimum size before content arrives; reserving the loading state’s height avoids a visible resize.
- Safari: popup creation is slower, so the delayed loading state appears more often. Make sure it looks intentional.
- All three: the not-applicable state depends on knowing the current tab’s URL, which requires
activeTab(granted by opening the popup) or host access — see handling restricted URLs and tab permissions.
Verification
- Force each state and confirm it renders with a sentence and the expected action:
1for (const kind of ["first-run", "empty", "not-applicable", "signed-out", "error", "loading"]) {
2 render({ kind, error: { status: 500 } });
3 console.log(kind, document.querySelector("main").innerText.split("\n")[0]);
4}
Execution context: the popup’s DevTools console. A blank first line for any state is a missing view.
- Open the popup on
chrome://extensionsand confirm the not-applicable state, not an error. - Disconnect the network, open the popup, and confirm cached items appear with the offline note.
- Install fresh, open the popup, close it without saving, reopen it, and confirm first-run is still shown; save once and confirm it is gone.
FAQ
Should the empty state show example content?
A single illustrative line is fine; fake items are not — users mistake them for real data or try to click them.
How long can a loading state reasonably show?
If it regularly shows for more than a second, the popup is waiting on something it should not be. Move the dependency off the critical path rather than improving the spinner.
Should errors include a “report” button?
Only if reporting is one click and sends something useful. A button that opens an empty email is worse than a clear sentence and a retry.
Related
- Loading popup data without a flash of empty UI — why the loading state should be rare.
- Rendering long lists in a popup — the populated state at scale.
- Preserving popup state when it closes — keeping the user’s place between states.
- Popup interface design — the parent guide.