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.
Prerequisites checklist
Before writing popup code, confirm all of the following:
"action": { "default_popup": "popup.html" }declared inmanifest.json— without this the toolbar button does nothing.- All JavaScript loaded from external files; no inline
<script>tags, nojavascript:hrefs (MV3 CSP rejects them). chrome.storage.localorchrome.storage.syncavailable for state persistence — nolocalStorageis 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:
| 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.
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.
| 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.
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
localStoragefor cross-context state: data written tolocalStoragein the popup is not visible to the service worker. Usechrome.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.
Related
- Building responsive popups with Tailwind CSS — Tailwind config, viewport locking, breakpoint overrides.
- Fixing popup size and overflow issues — diagnose collapse, overflow scrollbars, and clipped content.
- Keyboard Shortcuts & Commands — pair popup interactions with global shortcuts.
- Options Page Layouts — for settings that outgrow the popup’s 600 px height.
- Internationalization & Accessibility — translated labels, focus order and announcements in the same 360 px.
- Up to UI/UX Patterns & Interactive Components.