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.

Choosing the right surface for a piece of extension UIPopup, options page, side panel, context menu and injected in-page UI compared on lifetime, how the user reaches them and how much space they get.SurfaceLifetimeOpened bySpacePopupUntil blurToolbar click800 × 600 maxOptions pageWhole tabExtensions menuFull pageSide panelAcross navigationAction or APINarrow columnContext menuOne interactionRight clickMenu items onlyInjected in-page UIPage lifetimeAutomaticWhole viewport
Lifetime is the deciding column: anything the user must keep visible while they browse cannot live in the popup.
 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

The constraints every extension UI surface inheritsExtension pages run under a strict content security policy, a fixed viewport, the user's colour scheme and the browser's own keyboard model.Strict CSPno inline handlers, no remote scriptexternal files onlyFixed viewportpopup capped at 800 × 600no window resize APIUser colour schemeprefers-color-schemeboth themes at AA contrastBrowser keyboard modelreserved shortcuts winfour command slots
These four constraints apply to every surface on this page — designing against them once saves reworking each surface later.

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.

Context menus

The right-click menu lets an extension act on exactly what the user pointed at — a selection, a link, an image, an editable field — without opening any UI of its own. Items are registered once in runtime.onInstalled, persist across worker restarts, and are filtered by the browser before the menu opens: contexts chooses the kind of target, and documentUrlPatterns and targetUrlPatterns narrow it to the pages and links where the action can work. Those filters cost nothing at click time and keep the menu relevant; state the filters cannot express is applied through visible and enabled, updated when that state changes.

1chrome.runtime.onInstalled.addListener(async () => {
2  await chrome.contextMenus.removeAll();
3  chrome.contextMenus.create({ id: "reader:quote", title: "Save “%s” as a quote", contexts: ["selection"] });
4});

Execution context: the service worker. Clearing before creating makes the registration idempotent across updates, and %s puts the selected text into the item’s title with no code. A click grants activeTab for the tab, so the handler can read or inject without host permissions. See context menus and right-click actions.

The toolbar: badge, icon, title and notifications

The toolbar button is the only part of an extension visible all the time, which makes its badge, icon and tooltip the most valuable signals the extension has — and the easiest to wear out. A badge that is always non-zero becomes invisible within days. The durable pattern is to derive every signal from stored state in one function, run it from storage.onChanged and on startup, and reserve alert colours for problems the user can act on. System notifications interrupt the user in every application and belong only to events they are waiting for or problems that block the product, grouped and rate-limited by the extension itself.

1await chrome.action.setBadgeText({ tabId, text: blocked ? String(blocked) : "" });

Execution context: the service worker. A tabId scopes the badge to one tab; an empty string clears it, which is how “nothing to report” should look. See notifications, badges and the action API.

Side panels and DevTools panels

Some features need to stay visible while the user works in the page — notes, a reading companion, a review tool — and the popup closes the moment the page is clicked. Chrome’s side panel persists across navigations and can be scoped per tab; Firefox offers the comparable sidebarAction; Safari has neither. Opening the panel programmatically requires a user gesture, so sidePanel.open must be called directly in a click, command or menu handler before any await. DevTools panels are a separate surface for developer tools, existing only while DevTools is open and tied to one inspected tab. See side panel and DevTools interfaces.

The address bar

For search-shaped extensions, an omnibox keyword is the fastest interface available: the user types the keyword and a space in the address bar, and every keystroke after that goes to the extension as a suggestion request. Suggestions must be answered from an in-memory index quickly enough to keep pace with typing, descriptions use a small XML markup that must be escaped, and Enter arrives with a disposition — current tab, new foreground tab, new background tab — that the handler must honour. Chrome and Firefox support it; Safari does not, so a keyboard-reachable popup search sharing the same index is the portable companion. See omnibox and address bar integration.

Choosing the right surface

Each surface in this section fits a particular kind of interaction, and many UX problems in extensions come from putting a feature in the wrong one. Glanceable state belongs on the toolbar, in the badge or icon. A single quick action belongs in the popup, or in the context menu when it applies to something the user pointed at. Work that runs alongside the page belongs in a side panel or in injected UI. Configuration belongs on the options page. Long, focused tasks — imports, dashboards, onboarding — belong in an extension page opened in a tab. Search belongs in the address bar where the omnibox exists. Asking which surface fits before designing the UI avoids most of the stretching and workarounds that make extension interfaces feel awkward.

Internationalisation

Extensions are listed in every store region from their first day, and a large share of installs typically come from outside the developer’s language. Translation runs through two separate mechanisms. The extension’s UI uses chrome.i18n.getMessage against _locales/<lang>/messages.json, with missing keys falling back to the default locale. Manifest fields — the name, description, toolbar title and command descriptions — use __MSG_key__ placeholders resolved by the browser at install. The store listing is translated separately again, in each store’s dashboard. Routing every visible string through the i18n API from the first screen, and laying out with logical CSS properties so right-to-left locales work, costs almost nothing when done early and a great deal when retrofitted. See internationalisation and accessibility.

1document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = chrome.i18n.getMessage(el.dataset.i18n); });

Execution context: any extension page, run from an external script since inline scripts are blocked. Setting textContent rather than markup keeps translations — which pass through translators’ hands — out of the parsing path.

Consistency across surfaces

An extension with a popup, an options page, a side panel and injected UI is several interfaces that users experience as one product. Inconsistency between them — different wording for the same action, different colours for the same state, a setting that looks different in the popup and on the options page — makes the whole extension feel less trustworthy. Sharing a small set of components and design tokens across every extension page, and deriving every surface’s state from the same storage keys, keeps them aligned without effort. Injected UI is the exception: it lives inside someone else’s page, and it should adopt only the minimum of the extension’s styling needed to be recognisable, inside a shadow root that keeps both sides’ CSS apart.

Speed is part of the interface

Every surface in this section is judged partly by how quickly it appears. The popup is opened many times a day and must paint real content on its first frame; the side panel must not stall while the worker starts; injected UI must not add visible delay to the page it lives in; omnibox suggestions must keep pace with typing. The common technique is the same everywhere: render from data already on disk, correct from the worker afterwards, and never put a network request or a cold worker between the user’s action and the first visible response. Treating speed as a design requirement, with measured targets per surface, keeps it from eroding one feature at a time.

Measuring what users actually do

Design debates about extension UI are easier to settle with a few locally collected numbers, shared only with consent: how long the popup stays open, which action is taken most often, which settings are changed from their defaults, which omnibox suggestions are chosen. Those numbers usually show that most sessions are short and most users touch one or two controls, which argues for smaller, more focused surfaces rather than more features — and they show clearly when a new feature is being ignored.

Keep such measurement minimal and honest. Record counts and durations, never page content or URLs; store them on the device; and show users what would be shared before they opt in. Measurement that users can inspect and turn off is measurement they are comfortable leaving on, and a small, trusted data set is worth far more for design decisions than a large one collected quietly.

Revisit the numbers after each significant release, alongside user feedback, and let them retire features as readily as they justify new ones.

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. Notifications, badges and the action API covers the toolbar surface every extension owns and the rules for using it without becoming noise. Internationalization and accessibility covers the two concerns that share one infrastructure in an extension — a translation and labelling pass over static markup, plus keyboard order, right-to-left layout and live announcements in surfaces with no browser chrome.

The address bar is the newest surface covered here: omnibox and address bar integration shows how a keyword turns every keystroke into a query for your extension, and what to build where the omnibox does not exist. The toolbar itself gets more attention too — badge text, colour and count patterns and enabling and disabling the toolbar action per tab cover the signals users see all day.

Omnibox & Address Bar Integration

Add a keyword to the browser's address bar with chrome.omnibox — suggestions as the user types, handling Enter and Alt-Enter, safe navigation, and what Firefox and Safari offer instead.

4 topics

  • • Handling Omnibox Input Entered Navigation
  • • Omnibox Support and Alternatives Across Browsers
  • • Providing Omnibox Suggestions Asynchronously
  • + 1 more

Internationalization & Accessibility

Localise and make accessible every MV3 extension surface: the i18n API, _locales bundles, CSP-safe DOM translation, keyboard navigation, RTL layouts and live announcements.

6 topics

  • • Testing Extension UI with a Screen Reader
  • • Translating Manifest Fields and Store Listings
  • • Announcing Dynamic Updates to Screen Readers
  • + 3 more

Notifications, Badges & the Action API

Signal state from an MV3 service worker with chrome.action badges, icons and titles plus chrome.notifications — including per-tab state, click routing, and Chrome, Firefox and Safari differences.

6 topics

  • • Badge Text, Colour and Count Patterns
  • • Drawing Dynamic Action Icons with OffscreenCanvas
  • • Enabling and Disabling the Toolbar Action per Tab
  • + 3 more

Side Panel & DevTools Interfaces

Implement persistent side panels and DevTools extension panels in MV3 using chrome.sidePanel and chrome.devtools.panels — per-tab control, lifecycle, and cross-browser gaps.

6 topics

  • • Building a Custom DevTools Panel
  • • Inspecting Page State from a DevTools Extension
  • • Opening the Side Panel from a User Gesture
  • + 3 more

Context Menus & Right-Click Actions

Build chrome.contextMenus items in MV3: register in onInstalled, handle onClicked in the service worker, create parent/child menus, and gate visibility by context type.

6 topics

  • • Context Menu Contexts and Target Filters
  • • Handling Selection and Link Context Menu Clicks
  • • Keeping Context Menu IDs Stable Across Updates
  • + 3 more

Keyboard Shortcuts & Commands

Register and handle keyboard shortcuts in MV3 extensions using the chrome.commands API — manifest declaration, service worker listeners, per-OS suggested keys, the 4-shortcut limit, and cross-browser rebinding via chrome://extensions/shortcuts.

6 topics

  • • Letting Users Rebind Extension Shortcuts
  • • Shortcut Behaviour in Firefox and Safari
  • • The _execute_action Command and the Four-Shortcut Limit
  • + 3 more

Options Page Layouts

Design scalable options page UIs for MV3 extensions — sidebar nav, tabbed sections, responsive grids, form components, autosave UX, and dark mode support.

6 topics

  • • Accessible Form Controls for Extension Settings
  • • Search and Filter in a Large Options Page
  • • Embedding Options in the Popup
  • + 3 more

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.

6 topics

  • • Popup Loading and Empty States
  • • Rendering Long Lists in a Popup
  • • Dark Mode in Extension Popups
  • + 3 more