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.
Table of Contents
- Root cause: the same data, two surfaces with different constraints
- Step 1. Put the field definitions in one module
- Step 2. Render from the definitions, not from markup
- Step 3. Give the popup a layout that fits
- Step 4. Keep the surfaces in step while both are open
- Step 5. Link out rather than cramming everything in
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
Step 5. Link out rather than cramming everything in
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.
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+:
openOptionsPageopens 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+:
openOptionsPageis 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
- Add a field to the schema with
popup: falseand confirm it appears on the options page and not in the popup, with no other change. - 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.
- Open the popup at its narrowest and confirm no row overflows and the popup does not resize as values load.
- 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.
Related
- Validating and resetting options forms — the validator both surfaces share.
- Syncing options form state with chrome storage — the write path underneath.
- Preserving popup state when it closes — the popup’s own lifetime problem.
- Options Page Layouts — the guide this page belongs to.