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.
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.
Popup interface
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
.jsfiles loaded via<script src="...">. - Replace
innerHTMLassignments with DOM methods (createElement,appendChild,replaceChildren) or use a trusted-types-safe template engine. - Bundlers that emit
evalfor source maps (Webpack’sdevtool: '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-srcdefaults to'self' 'unsafe-inline'in Chrome’s extension CSP, so inline<style>blocks andstyleattributes 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
| Surface | Chrome / Edge | Firefox (≥ 109) | Safari (≥ 17) |
|---|---|---|---|
action.default_popup | Full support | Full support (browser_action merged into action) | Full support |
options_ui.open_in_tab | Supported | Supported | Supported; embedded sheet not available |
commands (4 max) | Full support | Full support (browser.commands) | Supported; system shortcut conflicts possible |
contextMenus | Full support | browser.contextMenus; requires menus permission | Supported; no image/video context types |
sidePanel + chrome.sidePanel | Chrome 114+ / Edge 114+ | Not supported | Not supported |
devtools.panels | Full support | Full support | Partial; inspectedWindow.eval restricted |
| CSS custom properties in popup | Full support | Full support | Full support |
prefers-color-scheme in extension pages | Full support | Full support | Full support |
eval / new Function in extension pages | Blocked by MV3 CSP | Blocked | Blocked |
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.
Related
- Omnibox & Address Bar Integration — a keyword search in the browser’s address bar.
- Popup interface design — initialization, sizing, and responsive layout.
- Options page layouts — tabbed UI and storage-synced form state.
- Keyboard shortcuts and commands — manifest commands, service-worker handlers, conflict resolution.
- Context menus and right-click actions — dynamic menu items and selection handling.
- Side panel and DevTools interfaces — persistent side panel and DevTools panel bridge.
- Notifications, badges and the action API — the toolbar surface and when to use it.
- Internationalization and accessibility — i18n bundles, keyboard order, RTL and live regions.
- Core APIs & Cross-Browser Data Management — storage, messaging, and scripting APIs that power every UI surface.
- Manifest V3 Architecture & Extension Lifecycle — service worker fundamentals, popup architecture, and options page configuration.
- Testing, Debugging & Performance Optimization — tools and patterns for validating extension UIs.