UI/UX Patterns & Interactive Components

Build production-ready extension UIs in MV3: popup design, options pages, keyboard shortcuts, context menus, and side panels — with accessibility, theming, and CSP patterns.

Manifest V3 eliminated persistent background pages, and that single change rewrites the rules for every UI surface an extension exposes. Popups can no longer assume a warm background process; options pages must hydrate their own state from storage on every open; keyboard shortcut handlers execute inside a service worker that may have been evicted seconds earlier. Getting these surfaces right requires understanding which execution context owns each piece of chrome API surface and how state flows between them without a long-lived coordinator. The popup interface design guide is the best first stop because the popup is both the most visible UI surface and the most constrained one.

MV3 extension UI surfaces and their execution contextsFive UI surfaces — popup, options page, commands, context menu, side panel / DevTools — mapped to the execution contexts that own them and the shared service worker they communicate through.Service workerbackground · event-drivenPopupaction.default_popupOptions pageoptions_ui / options_pageCommandscommands · onCommandContext menuscontextMenus APISide panel / DevToolschrome.storageshared state layer

Manifest declaration surface

All UI surfaces are declared once in manifest.json. The browser reads these at install time to register entry points; none of them are configurable at runtime. Getting the declaration wrong — using options_page instead of options_ui, or omitting the side_panel key — results in the surface simply not appearing, with no runtime error.

 1{
 2  "manifest_version": 3,
 3  "name": "My Extension",
 4  "version": "1.0",
 5  "action": {
 6    "default_popup": "popup.html",     // toolbar button → popup window
 7    "default_icon": "icon48.png",
 8    "default_title": "Open panel"
 9  },
10  "options_ui": {
11    "page": "options.html",            // opens in a tab within chrome://extensions
12    "open_in_tab": true                // false = embedded sheet in Chrome 111+
13  },
14  "commands": {
15    "_execute_action": {               // reserved: opens the popup via keyboard
16      "suggested_key": { "default": "Alt+Shift+U" },
17      "description": "Open UI"
18    },
19    "toggle-feature": {
20      "suggested_key": { "default": "Alt+Shift+F", "mac": "MacCtrl+Shift+F" },
21      "description": "Toggle the main feature on/off"
22    }
23  },
24  "side_panel": {
25    "default_path": "sidepanel.html"  // Chrome 114+; persistent beside the page
26  },
27  "permissions": ["contextMenus", "sidePanel", "storage", "activeTab"],
28  "background": {
29    "service_worker": "sw.js",
30    "type": "module"
31  }
32}

Execution context: Parsed by the browser at install and update time. Chrome and Edge fully support all five keys. Firefox (≥ 109 for MV3) does not yet support side_panel; use progressive enhancement. Safari (≥ 17) supports action, options_ui, and commands; side_panel is not supported as of Safari 18. The commands key supports at most 4 suggested shortcuts per extension; Chrome ignores any extras.

The popup is an HTML page that opens in a constrained floating window when the user clicks the toolbar icon. Its lifetime is brutally short: it is created on click and destroyed when it loses focus. Every initialization must be synchronous-looking to the user — meaning you read state from chrome.storage immediately on DOMContentLoaded, before painting anything meaningful.

 1// popup.ts — hydrate on open, persist on change
 2document.addEventListener("DOMContentLoaded", async () => {
 3  const { enabled, theme } = await chrome.storage.local.get(["enabled", "theme"]);
 4  applyTheme(theme ?? "system");
 5  renderToggle(enabled ?? false);
 6});
 7
 8document.getElementById("toggle")!.addEventListener("change", async (e) => {
 9  const enabled = (e.target as HTMLInputElement).checked;
10  await chrome.storage.local.set({ enabled });
11  // Notify the service worker so it can update alarms or rules
12  chrome.runtime.sendMessage({ type: "SET_ENABLED", payload: enabled });
13});
14
15function applyTheme(theme: string) {
16  document.documentElement.setAttribute("data-theme", theme);
17}

Execution context: Popup runs in its own renderer process, separate from both the service worker and any content scripts. chrome.storage, chrome.runtime.sendMessage, and chrome.tabs (with activeTab granted by the user’s click) are available. window.localStorage is accessible but scoped to the extension origin — do not use it as a cross-context state store. Firefox and Safari popup windows close on the same focus-loss trigger as Chrome; Safari additionally enforces a 600 px maximum popup width. For architectural depth, see popup interface design.

Options page

The options page is a full HTML document, persistent as long as its tab is open. It is the right place for configuration that the user sets once and rarely revisits — themes, feature flags, account links, data export. Because it outlives multiple service worker evictions during its session, it must subscribe to chrome.storage.onChanged rather than reading once on load.

 1// options.ts — live-sync settings form with storage
 2async function initOptions() {
 3  const prefs = await chrome.storage.local.get(null); // load everything
 4  populateForm(prefs);
 5}
 6
 7chrome.storage.onChanged.addListener((changes, area) => {
 8  if (area !== "local") return;
 9  for (const [key, { newValue }] of Object.entries(changes)) {
10    syncFieldToUI(key, newValue); // update only changed fields
11  }
12});
13
14document.getElementById("save-btn")!.addEventListener("click", async () => {
15  const formData = collectFormValues();
16  await chrome.storage.local.set(formData);
17  showSavedBanner();
18});
19
20initOptions();

Execution context: Options page runs in an extension-origin tab, giving it full access to chrome.storage, chrome.tabs, chrome.runtime, and most other extension APIs. It does not inherit any content-script restrictions. chrome.storage.onChanged fires in every open extension context simultaneously, so if both the popup and the options page are open at the same time, both listeners fire on each write — guard against double-processing. For tabbed layout patterns, see options page layouts.

Keyboard shortcuts and commands

The commands manifest key registers keyboard shortcuts that the browser intercepts before any web page sees the keydown event. The handler always fires in the service worker via chrome.commands.onCommand — never in the popup or options page. This means any DOM manipulation triggered by a shortcut must go through a chrome.tabs.sendMessage call to a content script, or must update storage and let the UI surface react via onChanged.

 1// sw.js — handle declared commands
 2chrome.commands.onCommand.addListener(async (command, tab) => {
 3  if (command === "toggle-feature") {
 4    const { enabled } = await chrome.storage.local.get("enabled");
 5    await chrome.storage.local.set({ enabled: !enabled });
 6    // Push update to any open popup via storage.onChanged, no direct DOM access
 7  }
 8  if (command === "_execute_action") {
 9    // browser opens the popup natively; no code needed here
10  }
11});

Execution context: chrome.commands.onCommand fires in the service worker background. The service worker may have been dormant — the browser wakes it to deliver the event. Chrome enforces a limit of 4 custom shortcuts per extension. Firefox fires the identical event under browser.commands.onCommand with native Promises. Safari supports commands since Safari 17 but does not guarantee that suggested keys will not conflict with system shortcuts — always test on macOS. For conflict resolution strategies, see keyboard shortcuts and commands.

Accessibility, theming & CSP

Accessibility

Extension pages are real HTML documents and must meet the same WCAG 2.1 AA bar as any web page. The patterns that most often fail in extensions: popup buttons with no accessible label, options form fields missing <label> associations, and dynamic content updated via innerHTML that screen readers never see because no aria-live region announces the change.

 1<!-- popup.html — accessible toggle with live region -->
 2<button
 3  id="toggle"
 4  role="switch"
 5  aria-checked="false"
 6  aria-label="Enable feature"
 7>
 8  <span class="thumb" aria-hidden="true"></span>
 9</button>
10<div aria-live="polite" aria-atomic="true" id="status-msg"></div>
1// After state change, announce it
2document.getElementById("status-msg")!.textContent = enabled
3  ? "Feature enabled"
4  : "Feature disabled";
5(document.getElementById("toggle") as HTMLElement).setAttribute(
6  "aria-checked",
7  String(enabled)
8);

Execution context: Extension page renderer. ARIA live regions work in Chrome, Firefox, and Edge. Safari VoiceOver reads aria-live="polite" correctly in extension pages since Safari 16. Do not rely on role="alert" for repeated announcements — VoiceOver may suppress identical strings; change the text on each update.

Theming

CSS custom properties set at :root are the most portable theming approach for extension UIs. Read the OS preference with prefers-color-scheme and complement it with a user-controlled setting stored in chrome.storage.local. Apply the setting via a data-theme attribute on <html> so a single CSS selector handles both.

 1/* popup.css */
 2:root {
 3  --bg: #ffffff;
 4  --fg: #111827;
 5  --accent: #2563eb;
 6}
 7[data-theme="dark"] {
 8  --bg: #1e1e2e;
 9  --fg: #cdd6f4;
10  --accent: #89b4fa;
11}
12@media (prefers-color-scheme: dark) {
13  :root:not([data-theme="light"]) {
14    --bg: #1e1e2e;
15    --fg: #cdd6f4;
16    --accent: #89b4fa;
17  }
18}

Execution context: Static CSS loaded by any extension HTML page. Works identically across Chrome, Firefox, and Safari. Avoid color-scheme: dark on the <html> element without testing — Safari on macOS applies it to system scrollbars and form controls, which can produce unexpected contrast in the popup’s constrained viewport.

Content Security Policy

MV3 enforces a strict CSP on all extension pages by default: no inline <script> blocks, no inline event handlers (onclick="..."), no eval, no new Function, and no remote script tags. You cannot relax script-src in MV3 — Chrome rejects any manifest that tries to add 'unsafe-eval'.

Practical consequences:

  • Move all scripts to external .js files loaded via <script src="...">.
  • Replace innerHTML assignments with DOM methods (createElement, appendChild, replaceChildren) or use a trusted-types-safe template engine.
  • Bundlers that emit eval for source maps (Webpack’s devtool: 'eval') must be set to 'source-map' or 'inline-source-map' for production and development builds targeting extension pages.
  • Styles can be inline or external; the CSP does not restrict CSS. style-src defaults to 'self' 'unsafe-inline' in Chrome’s extension CSP, so inline <style> blocks and style attributes are permitted.

Firefox and Safari enforce the same script-src restriction. Firefox additionally rejects 'wasm-unsafe-eval' in extension pages unless the wasm permission is declared.

Cross-browser compatibility matrix

SurfaceChrome / EdgeFirefox (≥ 109)Safari (≥ 17)
action.default_popupFull supportFull support (browser_action merged into action)Full support
options_ui.open_in_tabSupportedSupportedSupported; embedded sheet not available
commands (4 max)Full supportFull support (browser.commands)Supported; system shortcut conflicts possible
contextMenusFull supportbrowser.contextMenus; requires menus permissionSupported; no image/video context types
sidePanel + chrome.sidePanelChrome 114+ / Edge 114+Not supportedNot supported
devtools.panelsFull supportFull supportPartial; inspectedWindow.eval restricted
CSS custom properties in popupFull supportFull supportFull support
prefers-color-scheme in extension pagesFull supportFull supportFull support
eval / new Function in extension pagesBlocked by MV3 CSPBlockedBlocked

What this section covers

The guides here each go deep on one surface. Popup interface design covers the sub-100 ms initialization pattern, size constraints, overflow fixes, and responsive layout with Tailwind CSS. Options page layouts explains tabbed layout construction, form state synchronization with storage, and schema versioning for settings that outlive extension updates. Keyboard shortcuts and commands covers the full command lifecycle from manifest declaration to service-worker handler, global vs. in-page scope, and strategies for detecting and resolving shortcut conflicts. Context menus and right-click actions walks through dynamic menu generation, selection-aware items, and delegating click handling to content scripts. Side panel and DevTools interfaces addresses the Chrome-only sidePanel API, DevTools panel creation, and the port-based communication bridge between the inspected page and the extension background.