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:

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.

 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.

MV3 Constraints box

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