Options Page Configuration
Register options_ui or options_page in Manifest V3, persist user preferences through chrome.storage.sync, and keep every extension context in sync via onChanged.
Register options_ui or options_page in Manifest V3, persist user preferences through chrome.storage.sync, and keep every extension context in sync via onChanged.
Every extension that lets users customise its behaviour needs a dedicated settings surface. Manifest V3 provides two declarative manifest keys — options_ui and options_page — that tell the browser where to find that surface and how to display it. Getting the registration right is the first step; the harder work is making preferences durable and keeping every extension context — popup, content script, service worker — in agreement the moment a user saves a change. This guide is part of the Manifest V3 Architecture & Extension Lifecycle section and covers the full lifecycle from manifest entry to reactive synchronisation.
"storage" listed in "permissions" in manifest.json — every chrome.storage.* call throws a runtime error without it.options.html file committed in the extension package at the path referenced by the manifest key.<script> tags in options.html — MV3’s Content Security Policy forbids them. All logic must live in external .js files referenced via <script src="...">.chrome.tabs from the options page (for example, to message an active content script), "tabs" or "activeTab" must also be in "permissions".chrome://extensions or in a full browser tab — the choice affects layout space, scrolling, and what browser chrome surrounds the interface.The browser learns about your options page entirely through manifest.json. There are two keys and they are mutually exclusive; you can declare one or the other, not both.
1{
2 "manifest_version": 3,
3 "name": "My Extension",
4 "version": "1.0.0",
5 "permissions": ["storage"],
6
7 // Option A — embedded inside chrome://extensions (recommended for simple UIs)
8 "options_ui": {
9 "page": "options.html",
10 "open_in_tab": false // omitting this key has the same effect
11 },
12
13 // Option B — standalone browser tab (uncomment and remove options_ui above)
14 // "options_page": "options.html"
15
16 // Using options_ui with open_in_tab: true also opens a standalone tab,
17 // making it functionally equivalent to options_page.
18}
Execution context: Parsed by the extension host at install and update time. Neither key creates a running context on its own; the page is loaded only when the user clicks “Extension options” in chrome://extensions or your extension calls chrome.runtime.openOptionsPage().*
options_page vs options_ui — the practical distinction
options_page is the legacy key introduced in MV2. It always opens the specified HTML file in a full browser tab. options_ui is the preferred MV3 key and supports the open_in_tab boolean: when false (the default), Chrome renders the page in a constrained iframe embedded directly inside the chrome://extensions panel; when true, it behaves exactly like options_page and opens a standalone tab.
The embedded panel has a fixed maximum width set by the browser chrome surrounding it and cannot be scrolled independently of the chrome://extensions page on older Chrome versions. It also runs in an opaque-origin iframe context, which blocks certain third-party resources and can surface surprising CSP failures. If your settings interface is complex or relies on external fonts and media, open_in_tab: true gives you a proper browsing context without those constraints.
The options page is a plain HTML document. Keep it lightweight — it is a settings form, not an application. On load, read the current stored values and populate the form fields before the user has a chance to interact.
1// options.js — loaded via <script src="options.js" defer> in options.html
2document.addEventListener('DOMContentLoaded', async () => {
3 // Pull stored preferences; supply defaults inline for first run
4 const prefs = await chrome.storage.sync.get({
5 theme: 'system',
6 notifications: true,
7 badgeColor: '#2563eb',
8 });
9
10 document.getElementById('theme-select').value = prefs.theme;
11 document.getElementById('notifications-toggle').checked = prefs.notifications;
12 document.getElementById('badge-color').value = prefs.badgeColor;
13});
Execution context: Runs in the options page document, which has its own isolated JavaScript environment with access to the full chrome.* extension API surface. The page shares no memory with the service worker or popup.*
chrome.storage.sync.get accepts an object whose values become the defaults for any key not yet present in storage. This pattern means you never need to seed defaults separately on first install — the get itself is idempotent.
Write preferences back to storage as soon as the user changes a control. Avoid batching saves behind a “Save” button — immediate persistence means the user’s choices survive a browser crash or an accidental tab close.
1// options.js — continued
2document.getElementById('theme-select').addEventListener('change', async (e) => {
3 await chrome.storage.sync.set({ theme: e.target.value });
4});
5
6document.getElementById('notifications-toggle').addEventListener('change', async (e) => {
7 await chrome.storage.sync.set({ notifications: e.target.checked });
8});
9
10document.getElementById('badge-color').addEventListener('input', async (e) => {
11 // Debounce colour pickers — colour inputs fire on every pixel drag
12 clearTimeout(window._colorDebounce);
13 window._colorDebounce = setTimeout(async () => {
14 await chrome.storage.sync.set({ badgeColor: e.target.value });
15 }, 300);
16});
Execution context: Options page document. Each chrome.storage.sync.set call is throttled by the browser to at most one write per key per second (Chrome enforces a sustained rate of 1,800 writes per hour). Debouncing rapid inputs like colour pickers is therefore both a politeness and a practical requirement.*
chrome.storage.sync replicates data across the user’s signed-in Chrome profiles on other devices. Use it for genuine user preferences. If a setting is device-specific — a local file path or a hardware identifier — use chrome.storage.local instead, which has a higher quota and no cross-device sync.
The most important property of chrome.storage for options pages is that writes are broadcast to every open extension context simultaneously. You do not need message passing or a shared state object. Register an onChanged listener in any context that needs to react to preference updates.
1// options.js — keeps the form in sync if the user has options open in two tabs
2chrome.storage.onChanged.addListener((changes, areaName) => {
3 if (areaName !== 'sync') return;
4
5 if (changes.theme) {
6 document.getElementById('theme-select').value = changes.theme.newValue;
7 applyTheme(changes.theme.newValue);
8 }
9 if (changes.notifications !== undefined) {
10 document.getElementById('notifications-toggle').checked =
11 changes.notifications.newValue;
12 }
13});
14
15function applyTheme(theme) {
16 document.documentElement.dataset.theme =
17 theme === 'system'
18 ? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
19 : theme;
20}
Execution context: Options page document. The same onChanged event fires in the service worker and in any open popup or content script that has registered a listener — all at the same time, with no round-trip messaging required.*
The changes object passed to the listener maps each changed key to { oldValue, newValue }. Checking changes.notifications !== undefined (rather than truthiness) correctly handles the case where the user toggles notifications off, setting the value to false.
The service worker receives the same onChanged event without any wiring beyond a top-level listener registration. This is the correct way to propagate preference changes to background logic — not message passing, not polling.
1// background.js — top-level, registered synchronously
2chrome.storage.onChanged.addListener((changes, areaName) => {
3 if (areaName !== 'sync') return;
4
5 if (changes.badgeColor) {
6 // Update the browser action badge to reflect the new colour immediately
7 chrome.action.setBadgeBackgroundColor({
8 color: changes.badgeColor.newValue,
9 });
10 }
11
12 if (changes.notifications !== undefined && !changes.notifications.newValue) {
13 // User turned off notifications — clear any queued ones
14 chrome.notifications.getAll((all) => {
15 Object.keys(all).forEach((id) => chrome.notifications.clear(id));
16 });
17 }
18});
Execution context: Extension service worker (WorkerGlobalScope). The listener must be registered at the top level of background.js — not inside an async function or after any await — so the runtime captures it during the initial synchronous evaluation pass. See Service Worker Fundamentals for a detailed explanation of why this constraint exists.*
options.html — the extension’s default CSP disallows <script> tags with inline content and javascript: URLs. All logic must be in external files loaded via src.chrome.storage.sync quota limits — 100 KB total across all keys, 8 KB per item, 512 maximum items. Attempting to write beyond these limits rejects the returned Promise. Always wrap writes in try/catch and fall back to chrome.storage.local if quota is exceeded.sync area. Debounce or batch writes from high-frequency inputs.open_in_tab is false, Chrome renders the options page in an iframe whose dimensions are controlled by the chrome://extensions host page. Avoid fixed pixel widths wider than roughly 640 px and test scrolling behaviour explicitly.chrome://extensions iframe origin — the embedded panel is served from the extension’s own origin (chrome-extension://) inside an opaque-origin iframe context. Third-party iframes and certain fetch() requests targeting non-HTTPS origins may be blocked.localStorage for cross-context data — localStorage is available in the options page document but is invisible to the service worker and other contexts. Use chrome.storage for any preference that must be visible across the extension.open_in_tab on Firefox — Firefox MV3 supports options_ui with open_in_tab but ignores the browser_style key (which was deprecated in Chrome too). Do not rely on the browser injecting its own stylesheet into the options page.| Behaviour | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
options_ui support | Yes (Chrome 40+) | Yes | Yes (Safari 14+) |
open_in_tab: false embedded panel | chrome://extensions iframe | about:addons iframe | Safari Extensions preferences pane |
options_page (legacy key) | Supported | Supported | Supported |
chrome.storage.sync | Full support | Requires signed-in Firefox Account for actual sync; API works offline | Supported; syncs via iCloud if user is signed in |
onChanged cross-context broadcast | Yes | Yes | Yes |
chrome.storage.sync quota | 100 KB / 8 KB per item | Same limits as Chrome | Same limits as Chrome |
| Inline script CSP block | Enforced | Enforced | Enforced |
Firefox users who are not signed into a Firefox Account still have chrome.storage.sync available as an API — it just stores data locally without replication. Code does not need to branch on this; the behaviour is transparent to the extension.
Safari’s embedded panel appears inside the Safari Extensions preferences pane rather than a standalone chrome://extensions-style manager. The available width is narrower than Chrome’s panel, so responsive layout is especially important.
For a comprehensive treatment of how to structure the visual design and interaction patterns of an options page — including form layout, section grouping, save confirmation patterns, and responsive design — see the Options Page Layouts guide in the UI/UX Patterns & Interactive Components section.
onChanged listener must be registered synchronously at the top level.onChanged inside injected scripts to apply per-page behaviour governed by options.onChanged event shape.