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.
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.
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.<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.<body> — without it the popup collapses to zero on some platforms.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.
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:
| Dimension | Minimum | Recommended target | Maximum (hard) |
|---|---|---|---|
| Width | ~280 px | 360–400 px | 800 px |
| Height | ~80 px | 400–520 px | 600 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.
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.
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.
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.
| Approach | First paint | Bundle size | Recommendation |
|---|---|---|---|
| Vanilla TS + DOM | < 20 ms | 0 KB framework | Best for simple popups |
| Preact + htm | 25–40 ms | ~4 KB gzipped | Good balance |
| React 18 | 40–80 ms | ~45 KB gzipped | Justified only for complex state |
| Vue 3 (Composition) | 35–65 ms | ~22 KB gzipped | Reasonable for form-heavy UIs |
| Svelte (compiled) | 20–35 ms | ~2 KB gzipped | Excellent 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.
window.resizeTo(): resizing the popup programmatically requires a user gesture and is blocked in most contexts. Use CSS.'self' only. Build tools must emit external files.localStorage for cross-context state: data written to localStorage in the popup is not visible to the service worker. Use chrome.storage.local.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.
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.
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.
Configure Tailwind CSS for MV3 extension popups: override breakpoints, lock viewport dimensions, prevent overflow scrollbars, and ship a fast-loading popup under 80 ms.