Embedding Options in the Popup

Show settings inside an MV3 popup without duplicating the options page: a shared form module, the narrow-surface layout, which settings to omit, and linking out for the rest.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

Users expect to change the obvious setting from the popup rather than hunting through the extensions menu, so most extensions end up with the same controls in two places — and then with two implementations that drift. The popup is also the worst surface for a settings form: it is 360 pixels wide, it is destroyed on blur, and it cannot scroll the window. This page is part of Options Page Layouts and covers sharing one implementation across both surfaces without pretending they are the same.

Root cause: the same data, two surfaces with different constraints

The settings themselves are identical; everything around them is not. The options page has a full tab, survives navigation and can afford explanation. The popup has a fixed narrow box, a lifetime measured in seconds, and a user who came to do one thing. Sharing the storage layer while duplicating the markup is what produces drift; sharing a form module and varying only the field set is what does not.

The same setting on two surfacesAvailable width, lifetime, room for explanation and appropriate field types compared between the popup and the options page.ConstraintPopupOptions pageUsable width~360 pxFull tabLifetimeUntil blurWhole tabRoom for help textOne lineParagraphsSuits long listsNoYesSuits a quick toggleYesYesDestructive actionsNoYes, with confirm
Everything that differs is presentational — which is why exactly one layer, the presentation, should differ in the code.

Step 1. Put the field definitions in one module

Describe the settings as data, and let each surface decide which of them to render. The definition carries everything both surfaces need: type, label, default, validation and a flag for whether it belongs in the popup.

 1// settings-schema.js — shared by both surfaces
 2export const FIELDS = [
 3  { name: "enabled",     type: "boolean", label: "Enable on this site", default: true,  popup: true },
 4  { name: "mode",        type: "enum",    label: "Mode", options: ["off", "balanced", "aggressive"],
 5    default: "balanced", popup: true },
 6  { name: "pollMinutes", type: "integer", label: "Check every (minutes)", min: 15, max: 1440,
 7    default: 30,         popup: false },
 8  { name: "apiBase",     type: "url",     label: "API base URL", default: "https://api.example.com",
 9    popup: false },
10  { name: "blockedHosts", type: "list",   label: "Blocked hosts", default: [], popup: false },
11];
12
13export const DEFAULTS = Object.fromEntries(FIELDS.map((f) => [f.name, f.default]));
14export const POPUP_FIELDS = FIELDS.filter((f) => f.popup);

Execution context: Shared module imported by the popup, the options page and the service worker. Deriving DEFAULTS from the same array means a new setting cannot be added without a default, which is the omission that produces undefined values in the worker weeks later. The popup flag is the only place the surface split is expressed — everything downstream reads it rather than re-deciding.

Step 2. Render from the definitions, not from markup

One renderer that walks the field list produces both surfaces. Each surface passes its own field subset and its own container.

 1// settings-form.js
 2import { validateField } from "./options-validate.js";
 3
 4export function renderFields(container, fields, values, onChange) {
 5  container.replaceChildren();
 6
 7  for (const field of fields) {
 8    const row = document.createElement("div");
 9    row.className = "setting-row";
10
11    const label = document.createElement("label");
12    label.htmlFor = `f-${field.name}`;
13    label.textContent = field.label;
14
15    const control = createControl(field, values[field.name]);
16    control.id = `f-${field.name}`;
17    control.name = field.name;
18
19    control.addEventListener(field.type === "boolean" ? "change" : "input", () => {
20      const raw = field.type === "boolean" ? control.checked : control.value;
21      const result = validateField(field.name, String(raw), values);
22      if (result.state === "valid") onChange(field.name, field.type === "boolean" ? control.checked : result.value);
23    });
24
25    row.append(label, control);
26    container.append(row);
27  }
28}

Execution context: Popup or options page renderer. Attaching listeners here rather than in markup is what keeps both surfaces policy-compliant, since inline handlers are blocked in every extension page. Because validation comes from the same module the options page uses, a value rejected there is rejected here, with no second definition of what is acceptable.

Step 3. Give the popup a layout that fits

The shared renderer produces the same DOM; the stylesheet decides how it sits. In the popup that means a stacked row with the control beneath the label, and a hard limit on how many rows appear at all.

1/* popup.css — the narrow presentation of the same rows */
2.setting-row { display: grid; grid-template-columns: 1fr; gap: 0.25rem; padding: 0.5rem 0; }
3.setting-row label { font-size: 0.8125rem; color: var(--muted); }
4.setting-row input[type="checkbox"] { justify-self: start; }
5
6/* options.css — the wide presentation */
7.setting-row { display: grid; grid-template-columns: minmax(0, 18rem) 1fr; gap: 1rem; align-items: center; }
8@media (max-width: 32rem) { .setting-row { grid-template-columns: 1fr; } }

Execution context: Two stylesheets over identical markup. Keeping the difference entirely in CSS means a field added to the schema appears correctly on both surfaces with no further work — which is the property that stops the two from drifting. The options page keeps its own narrow breakpoint because it can be embedded in the add-ons manager at a width close to the popup’s.

One schema, one renderer, two presentationsThe shared field definitions feed a single renderer, which each surface styles differently and populates from the same storage.settings-schema.jsfields + defaultssettings-form.jsone rendereroptions-validate.jsone validatoreach surface supplies a filter and a stylesheetPopupPOPUP_FIELDS, stackedOptions pageall fields, two columns
Only the field filter and the stylesheet differ — everything below them is shared by construction.

Step 4. Keep the surfaces in step while both are open

A user can have the options page open in a tab and the popup open at the same time. storage.onChanged is what keeps them agreeing, and the guard against reacting to your own write is what keeps it from fighting the user.

 1// settings-form.js
 2export function watchExternalChanges(container, fields, apply) {
 3  chrome.storage.onChanged.addListener((changes, area) => {
 4    if (area !== "sync") return;
 5
 6    for (const field of fields) {
 7      const change = changes[field.name];
 8      if (!change) continue;
 9
10      const control = container.querySelector(`#f-${field.name}`);
11      if (!control) continue;
12
13      const next = field.type === "boolean" ? Boolean(change.newValue) : String(change.newValue ?? "");
14      const current = field.type === "boolean" ? control.checked : control.value;
15      if (next === current) continue;      // our own echo: leave the control alone
16
17      apply(control, field, change.newValue);
18    }
19  });
20}

Execution context: Popup or options page renderer. Comparing before applying is what prevents the popup from resetting a control the user is mid-way through changing, and it is the same guard described in storage.onChanged listener patterns. Because the popup is short-lived, this matters most on the options page — but registering it on both costs nothing and removes a class of surprise.

Some settings do not belong in a popup at any width: long lists, destructive actions, anything needing explanation. Offer them as a link, and open the options page properly rather than in a tab you construct by hand.

Does this setting belong in the popup?Decision tree routing a setting to the popup, to the options page, or to both, based on how often it changes and how much explanation it needs.How often does a user change this setting?Several times a sessionPopup and optionspopup: trueOne control, no help textthe label must be enoughOnce at setupOptions page onlypopup: falseRoom to explainand to validate properlyAlmost neverOptions, behind a headingadvanced sectionConfirm destructive onesnever in the popup
Frequency decides, not importance — a critical setting nobody changes twice belongs on the full page.
1// popup.js
2document.querySelector("#more-settings").addEventListener("click", async () => {
3  // Opens the options page in whatever form the manifest declares — tab or embedded.
4  await chrome.runtime.openOptionsPage();
5  window.close();
6});

Execution context: Popup renderer. chrome.runtime.openOptionsPage respects the options_ui configuration, including open_in_tab: false, which a hand-built chrome.tabs.create call does not — using it means the extension behaves the way the manifest says it should. Closing the popup afterwards is deliberate: leaving it open behind a newly focused tab is a small thing that reads as unfinished.

Cross-browser variation

  • Chrome 120+: openOptionsPage opens the page as configured. The popup is capped at roughly 800 × 600, so the field filter is a hard requirement rather than a preference.
  • Firefox 121+: the same API, and its options page is commonly embedded in the add-ons manager at a narrow width — test the wide presentation at around 500 pixels, not just at full tab width.
  • Safari 17+: openOptionsPage is supported; the popup is effectively narrower than Chrome’s, so keep the popup field set very small.
  • All engines: inline handlers are blocked on both surfaces, which is why the shared renderer attaches listeners in code.

Verification

  1. Add a field to the schema with popup: false and confirm it appears on the options page and not in the popup, with no other change.
  2. Change a shared setting in the popup with the options page open in a tab. The options page should update that control and nothing else.
  3. Open the popup at its narrowest and confirm no row overflows and the popup does not resize as values load.
  4. Click through to the full settings and confirm the page opens the way the manifest configures it, not always as a new tab.

FAQ

Should every setting appear in the popup?

No. The popup is for the one or two settings a user changes often. Everything else belongs behind the link, and deciding that in the schema keeps the judgement in one place.

Can I embed the options page in an iframe inside the popup?

You can, and it fits badly: the iframe carries the wide layout into a narrow box and adds a second document lifecycle to manage. Sharing a renderer gives the same reuse without either problem.

How do I stop the two forms drifting?

By having one schema, one renderer and one validator, and letting the surfaces supply only a filter and a stylesheet. Drift is what happens when the markup is duplicated.

Does the popup need the same validation as the options page?

Yes, and it gets it for free from the shared validator. A value that reaches storage from the popup is read by the same worker.

Other UI/UX Patterns & Interactive Components Resources