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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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

Popup states and what each must sayFirst-run, empty, not-applicable, loading, offline, signed-out and error states compared on how often they occur, what the user needs to know, and the primary action to offer.StateUser needs to knowPrimary actionFirst runWhat this doesDo the first thingEmpty (nothing saved)It's working, nothing yetSave this pageNot applicable hereWhy not, on this pageNone, or open settingsLoading (slow)Something is happeningNone — keep it briefOfflineShowing saved dataRetrySigned outWhat sign-in unlocksSign inErrorWhat failed, plainlyRetry or report
Every row needs a sentence and, where possible, one action — none of them should be a blank panel.

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.

From raw conditions to one rendered stateInstall freshness, sign-in, applicability, error, loading, connectivity and item count are reduced by ordered checks to a single state, which selects one view with a sentence and an action.ConditionssignedIn, online, items…popupState()ordered checks{ kind }exactly oneselect the matching viewSentencewhat is happeningOne actionwhat to do nextrole=statusannounced
The ordering is the product decision — it says which condition the user hears about first.

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.

Which state a new user sees over their first weekA new user's popup opens across a week move from first-run through empty to a populated list, with a brief offline state along the way.installday 7first-rununtil fir…list (1–3 items)early useofflineon a trainlist (growing)habit formingfirst save — first-run gone for goodcached list shown, retry offered
The first two states are seen for minutes in total — and decide whether the later ones are ever reached.

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.onLine is 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

  1. 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.

  1. Open the popup on chrome://extensions and confirm the not-applicable state, not an error.
  2. Disconnect the network, open the popup, and confirm cached items appear with the offline note.
  3. 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.

Other UI/UX Patterns & Interactive Components Resources