Popup Interface Design

Design MV3 extension popups that open fast, stay within the 800×600 px cap, load state from storage, respect CSP, and work across Chrome, Firefox, and Safari.

The extension popup is the first surface users interact with, and it has a hard constraint baked into every Chromium build: a maximum viewport of 800 × 600 px. Exceed that and the browser forces scrollbars; go below a workable minimum and content clips. This guide is part of UI/UX Patterns & Interactive Components and covers the exact rules that govern sizing, fast first paint, state hydration from storage, Content Security Policy, and framework choices for a popup that opens in under 100 ms.

The single most common mistake is treating the popup like a small web page. It is not — it has no persistent JavaScript runtime between opens, no localStorage access from the service worker, no inline scripts allowed, and no standard viewport. Every design decision flows from those four constraints.

MV3 popup lifecycle: open → hydrate → interact → closeSequence showing the browser creating the popup renderer, the popup reading state from chrome.storage.local on DOMContentLoaded, the user interacting, messages going to the service worker, and the renderer being destroyed on close.Browsertoolbar clickPopup rendererisolated page contextchrome.storage.local / .syncService workerwoken on message① open② DOMContentLoaded③ get()④ sendMessageRenderer destroyedon dismiss / blur⑤ close — JS state lostHard limit: 800 × 600 px maximum — enforced by the browser host, not CSSPractical minimum: 280 px wide × 80 px tall; typical design target: 360–400 px wide × 400–500 px tall

Prerequisites checklist

Before writing popup code, confirm all of the following:

  • "action": { "default_popup": "popup.html" } declared in manifest.json — without this the toolbar button does nothing.
  • All JavaScript loaded from external files; no inline <script> tags, no javascript: hrefs (MV3 CSP rejects them).
  • chrome.storage.local or chrome.storage.sync available for state persistence — no localStorage is accessible from the popup at all in some browser configurations, and it cannot survive service worker restarts regardless.
  • A minimum CSS width set on <body> — without it the popup collapses to zero on some platforms.

1. Declaring the popup in manifest.json

The action key replaces MV2’s browser_action and page_action. Only default_popup, default_icon, and default_title are valid sub-keys; there are no default_width or default_height keys in MV3 — dimensions are CSS-only.

 1{
 2  "manifest_version": 3,
 3  "name": "My Extension",
 4  "version": "1.0",
 5  "action": {
 6    "default_popup": "popup.html",         // path relative to extension root
 7    "default_icon": { "32": "icon32.png" },
 8    "default_title": "Open controls"
 9  },
10  "permissions": ["storage"]               // required for chrome.storage.*
11}

Execution context: Parsed by the extension host at install time. Changing default_popup requires a full extension reload (chrome://extensions → reload). Firefox reads the same action key since MV3 support landed; Safari requires Xcode re-export on manifest change.

2. Sizing constraints and layout rules

The browser enforces a maximum of 800 × 600 px. The popup window grows to fit its content up to that ceiling, then shows scrollbars. There is no API to set the popup size — the rendered <body> dimensions drive the window size. Practical constraints:

How the browser decides how big your popup isThe popup window is sized to its rendered content, clamped to the platform maximum, with no API to resize it afterwards.Document renderscontent decides intrinsic sizeClamped to the maximumabout 800 × 600Window paintedsize is now fixedso set the dimensions before first paintExplicit body widthe.g. 360pxmin-height on bodyprevents collapseoverflow-y on the listscroll inside, not outside
You control the popup's size only through its CSS — there is no resize call to fall back on.
DimensionMinimumRecommended targetMaximum (hard)
Width~280 px360–400 px800 px
Height~80 px400–520 px600 px

Set min-width on body to prevent collapse, max-height to prevent overflow scrollbars, and always set margin: 0 — browser default margins add unexpected size.

 1/* popup.css — loaded via <link> in popup.html */
 2*, *::before, *::after { box-sizing: border-box; }
 3
 4body {
 5  margin: 0;
 6  min-width: 320px;
 7  width: 360px;         /* drives the popup window width */
 8  max-height: 560px;    /* stays inside 600 px cap, accounting for OS chrome */
 9  overflow-y: auto;
10  font-family: system-ui, sans-serif;
11}

Execution context: Applied in the popup renderer process. The browser host reads the body’s rendered box model after first paint to size the native window. Setting both width and max-height prevents the two most common failure modes: collapse and overflow scrollbars. See fixing popup size and overflow issues for a full diagnosis guide.

3. Fast first paint and loading state from storage

The popup has no pre-loaded state — every open starts from scratch. The gap between the renderer appearing and the first chrome.storage read completing is visible as a flicker or blank frame. Eliminate it with two techniques: show a skeleton state immediately in HTML, then hydrate from storage on DOMContentLoaded.

 1// popup.ts
 2document.addEventListener('DOMContentLoaded', async () => {
 3  // Show skeleton immediately — storage read is async
 4  const skeleton = document.getElementById('skeleton');
 5  const content = document.getElementById('content');
 6
 7  const data = await chrome.storage.local.get(['settings', 'lastSyncedAt']);
 8  const settings = data.settings ?? { theme: 'light', enabled: true };
 9
10  // Render real content, remove skeleton
11  renderSettings(settings, content);
12  skeleton?.remove();
13
14  // Wire interactions after hydration
15  document.getElementById('toggle')?.addEventListener('change', async (e) => {
16    const enabled = (e.target as HTMLInputElement).checked;
17    await chrome.storage.local.set({ settings: { ...settings, enabled } });
18  });
19});

Execution context: Runs in the popup renderer (isolated page context). chrome.storage.local.get is asynchronous and resolves in roughly 1–5 ms on warm storage. The service worker is not involved in this read — storage is accessible directly from any extension context with the storage permission. Firefox uses browser.storage.local with the same async signature; Safari behaves identically but may be slower on cold first open.

4. CSP for popup scripts

MV3 enforces a strict Content Security Policy on all extension pages. The defaults are non-negotiable: no eval, no inline <script> blocks, no javascript: URLs.

What the extension content security policy allows in a popupInline scripts, inline handlers, eval, remote scripts, local bundles and inline styles under the default MV3 policy.ConstructAllowed?Workaround<script> inline blockBlockedExternal fileonclick attributeBlockedaddEventListenereval / new FunctionBlockedPrecompute at buildRemote <script src>BlockedBundle locallyLocal <script src>Allowed—style attributeAllowed—
Inline style attributes are the one inline form still allowed — everything script-shaped has to come from a local file.
 1<!-- popup.html — compliant structure -->
 2<!DOCTYPE html>
 3<html lang="en">
 4<head>
 5  <meta charset="UTF-8">
 6  <!-- Do NOT add a CSP meta tag — the extension host applies its own policy -->
 7  <link rel="stylesheet" href="popup.css">
 8  <title>Extension popup</title>
 9</head>
10<body>
11  <div id="skeleton" aria-hidden="false">Loading…</div>
12  <div id="content" aria-live="polite"></div>
13  <!-- External script only — inline <script> will be blocked -->
14  <script src="popup.js"></script>
15</body>
16</html>

Execution context: Parsed by the popup renderer. The extension host injects script-src 'self' automatically. Adding a <meta http-equiv="Content-Security-Policy"> tag does not override the host policy and is ignored. For looser policies (e.g., to allow a CDN font), declare content_security_policy.extension_pages in the manifest — but 'unsafe-eval' and 'unsafe-inline' for scripts are rejected regardless.

5. Framework choices

The popup’s ephemeral lifetime (it is destroyed on blur) means framework overhead compounds on every open. Benchmarks below are for a 360 × 420 px popup with ~30 interactive elements on a mid-range machine.

ApproachFirst paintBundle sizeRecommendation
Vanilla TS + DOM< 20 ms0 KB frameworkBest for simple popups
Preact + htm25–40 ms~4 KB gzippedGood balance
React 1840–80 ms~45 KB gzippedJustified only for complex state
Vue 3 (Composition)35–65 ms~22 KB gzippedReasonable for form-heavy UIs
Svelte (compiled)20–35 ms~2 KB gzippedExcellent compile-time optimization

Avoid Angular or full React Router setups — they push first-paint past 100 ms on cold start. Regardless of framework, always compile to a single external bundle; bundlers like Vite handle this correctly for building responsive popups with Tailwind CSS.

6. Designing for a surface open for seconds

The popup’s defining property is not its size but its lifetime. Most sessions last a few seconds: the user opens it, reads one thing or presses one button, and moves on. Every design decision should start from that fact, because a popup designed like a small web page — with navigation, tabs, multi-step flows and forms — fights the way people actually use it.

The strongest popups do one thing prominently. They show the state the user most often wants to know — active on this page or not, how many items are waiting, whether the last sync worked — and offer the single action the user most often wants to take, in the largest control on the surface. Secondary actions go below it, smaller; settings and anything that needs thought go to the options page or a side panel. If the popup needs a scroll bar on first open for the typical user, it is probably trying to do too much.

7. The states users actually see

A popup is designed with a full list of data and used in every other condition: the first open after install with nothing saved, pages where the extension does not apply, signed out, offline, the moment before data loads, an error from the server. Those states are where users decide whether the extension works, and each needs a sentence and, where possible, one action — never a blank panel or an endless spinner. Modelling them as one explicit state value, rather than scattered flags, prevents impossible combinations such as a spinner over an error message. The full set, and how to prioritise them, is in popup loading and empty states.

8. Speed as a design requirement

Because the popup is opened so often, its first frame is the most frequently seen screen the extension has. A delay of a few hundred milliseconds before content appears — caused by waiting on a cold service worker or a network request — is felt on every open and reads as sluggishness in the whole product.

The design implication is to render from data that is already on disk. The service worker keeps a small, display-ready summary in chrome.storage.session; the popup reads it and paints real content on the first frame, then refreshes in the background and patches the view in place if anything changed. Reserve the exact space the content will occupy, so a late correction never shifts the layout. For long lists, render the first screenful immediately and window the rest, as described in rendering long lists in a popup.

9. Visual design within the browser’s frame

The popup sits directly under the toolbar, surrounded by browser chrome, and it looks best when it feels like part of that chrome rather than a web page dropped into it. Follow the operating system’s colour scheme with prefers-color-scheme, use the system font stack at a size close to the browser’s own menus, and keep spacing compact. Avoid heavy shadows, large hero images and custom scroll bars; the browser already provides the frame, border and shadow. Most importantly, respect the user’s accessibility settings — reduced motion, increased contrast, larger default font sizes — because a fixed-size popup is where those settings most often break layouts. Dark mode specifics are in dark mode in extension popups.

10. When to leave the popup

Some features outgrow the popup. Anything the user needs to keep visible while working on the page belongs in a side panel or injected UI, because clicking the page closes the popup. Anything that needs a file picker, a long form or an authentication flow belongs in an extension page opened in a tab, because those interactions move focus and destroy the popup mid-task. Recognising these cases early, and designing a clear handoff — a button that opens the right surface with the user’s context already loaded — is better than stretching the popup to cover them. The lifecycle behind these limits is set out in why the popup closes and how to work with it.

11. Measuring whether the popup works

Popup quality is easy to argue about and easy to measure. Three numbers, collected locally and shared only with consent, answer most design questions. Time to first meaningful paint, measured inside the popup with performance.now(), says whether the render path depends on anything slow. The share of opens that end in the primary action says whether the most prominent control is the right one. And the share of opens that end within a second with no interaction says whether users are opening the popup just to check a state that could live in the badge instead.

Each number points at a specific change. A slow first paint means moving data into a pre-shaped storage summary. A low primary-action rate means the popup is leading with the wrong thing. A high rate of glance-and-close opens is a strong sign that the answer belongs on the toolbar — a badge or icon state — so users never need to open the popup at all. Reviewing these together after each significant release keeps the popup focused as features accumulate, which is when most popups start to sprawl.

A final practical habit is to test the popup at the smallest size a user might see it. Browsers cap popups at roughly 800 by 600 pixels, but users with large default font sizes, high display scaling or a narrow window see much less. Checking the layout at a large text size catches clipped buttons and overflowing labels before users report them, and the fixes are covered in fixing popup size and overflow issues.

MV3 constraints to design around

  • No persistent JS runtime: every open of the popup creates a new renderer. Any in-memory state from the previous open is gone.
  • No window.resizeTo(): resizing the popup programmatically requires a user gesture and is blocked in most contexts. Use CSS.
  • No inline scripts: CSP 'self' only. Build tools must emit external files.
  • No localStorage for cross-context state: data written to localStorage in the popup is not visible to the service worker. Use chrome.storage.local.
  • 800 × 600 hard ceiling: content that exceeds this in either axis causes native OS scrollbars that cannot be suppressed with CSS.
  • Popup closes on blur: navigating away or clicking outside destroys the renderer. Do not depend on the popup remaining open.

Cross-browser notes

Chrome and Edge share the same 800 × 600 cap and identical chrome.action surface. Firefox MV3 uses browser.action and permits the same maximum size but renders native scrollbars by default — add scrollbar-width: thin to avoid them. Firefox also retains a slight offset from the toolbar that can shift layout by 1–2 px.

Safari requires the extension to be rebuilt through Xcode and uses SFSafariExtensionViewController for the popup — the hard size limit is the same but the rendering path is different. CSS that relies on subpixel rendering may produce visible gaps. Test on Safari 17+ where MV3 support is most complete.

1// Cross-browser storage adapter
2const ext = (typeof browser !== 'undefined' ? browser : chrome);
3
4export const storage = {
5  get: (keys: string[]) => ext.storage.local.get(keys),
6  set: (items: Record<string, unknown>) => ext.storage.local.set(items),
7};

Execution context: Shared module imported by popup, options page, and service worker. Resolves the chrome vs browser namespace difference at runtime with zero build-time branching.

What this section covers

This guide introduced the hard sizing rules and core patterns. The child guides go deeper on specific challenges: building responsive popups with Tailwind CSS covers Tailwind breakpoint overrides and containment; fixing popup size and overflow issues diagnoses popups that render too small, too large, or with unexpected scrollbars.

Further guides in this topic

The guides below go deeper into specific popup interface design problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • 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.
  • Rendering Long Lists in a Popup — Show hundreds or thousands of items in a 600-pixel extension popup without a slow open — windowed rendering, content-visibility, keyboard navigation and restoring scroll position.
  • Designing a First-Run Popup — Design what an extension’s popup shows the first time it opens: detecting first run, one clear first action, permission and site-access explanations, sample content instead of empty states, pinning hints, and graduating to the regular popup.
  • Preserving Popup State When It Closes — Keep MV3 popup state across open and close cycles: why unload handlers are unreliable, writing on change, restoring scroll and form values, and what belongs in session versus local storage.
  • Search and Filter UI in a Popup — Build fast, accessible search and filtering inside an extension popup: an autofocused search box, instant filtering with debouncing, filter chips, highlighting matches, keyboard navigation of results, remembering the last query, and empty results.
  • 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.
  • Using Web Components in Extension Popups — Build extension popup and options UI with native web components: custom elements without a framework, shadow DOM styling with shared tokens, adoptedStyleSheets, form-associated elements, reusing components in content scripts, and the custom elements limitation in isolated worlds.

Designing a First-Run Popup

Design what an extension's popup shows the first time it opens: detecting first run, one clear first action, permission and site-access explanations, sample content instead of empty states, pinning hints, and graduating to the regular popup.

Search and Filter UI in a Popup

Build fast, accessible search and filtering inside an extension popup: an autofocused search box, instant filtering with debouncing, filter chips, highlighting matches, keyboard navigation of results, remembering the last query, and empty results.

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.

Using Web Components in Extension Popups

Build extension popup and options UI with native web components: custom elements without a framework, shadow DOM styling with shared tokens, adoptedStyleSheets, form-associated elements, reusing components in content scripts, and the custom elements limitation in isolated worlds.

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.

Rendering Long Lists in a Popup

Show hundreds or thousands of items in a 600-pixel extension popup without a slow open — windowed rendering, content-visibility, keyboard navigation and restoring scroll position.

Dark Mode in Extension Popups

Ship a light and dark popup that never flashes: resolving the theme before first paint under the extension CSP, storing the choice, and keeping both themes at AA contrast.

Preserving Popup State When It Closes

Keep MV3 popup state across open and close cycles: why unload handlers are unreliable, writing on change, restoring scroll and form values, and what belongs in session versus local storage.

Fixing Popup Size and Overflow Issues

Diagnose and fix MV3 extension popups that render too small, too large, show unexpected scrollbars, or clip content — covering the 800×600 cap, min-width, body sizing, dynamic reflow, and devicePixelRatio.

Building Responsive Popups with Tailwind CSS

Configure Tailwind CSS for MV3 extension popups: override breakpoints, lock viewport dimensions, prevent overflow scrollbars, and ship a fast-loading popup under 80 ms.