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.
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.
Prerequisites checklist
"storage"listed in"permissions"inmanifest.json— everychrome.storage.*call throws a runtime error without it.- An
options.htmlfile committed in the extension package at the path referenced by the manifest key. - No inline
<script>tags inoptions.html— MV3’s Content Security Policy forbids them. All logic must live in external.jsfiles referenced via<script src="...">. - If you plan to call
chrome.tabsfrom the options page (for example, to message an active content script),"tabs"or"activeTab"must also be in"permissions". - A clear decision on whether users should see the options page embedded inside
chrome://extensionsor in a full browser tab — the choice affects layout space, scrolling, and what browser chrome surrounds the interface.
Manifest registration
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.
1. Building the options page HTML and loading defaults
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.
2. Persisting user preferences via chrome.storage.sync
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.
3. Reactivity with chrome.storage.onChanged
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.
4. Reacting in the service worker
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.*
MV3 constraints to design around
- No inline scripts in
options.html— the extension’s default CSP disallows<script>tags with inline content andjavascript:URLs. All logic must be in external files loaded viasrc. chrome.storage.syncquota 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 tochrome.storage.localif quota is exceeded.- Sync rate limits — Chrome enforces at most 1,800 write operations per hour across the
syncarea. Debounce or batch writes from high-frequency inputs. - Embedded panel viewport constraints — when
open_in_tabisfalse, Chrome renders the options page in an iframe whose dimensions are controlled by thechrome://extensionshost page. Avoid fixed pixel widths wider than roughly 640 px and test scrolling behaviour explicitly. chrome://extensionsiframe origin — the embedded panel is served from the extension’s own origin (chrome-extension://) inside an opaque-origin iframe context. Third-party iframes and certainfetch()requests targeting non-HTTPS origins may be blocked.- No
localStoragefor cross-context data —localStorageis available in the options page document but is invisible to the service worker and other contexts. Usechrome.storagefor any preference that must be visible across the extension. open_in_tabon Firefox — Firefox MV3 supportsoptions_uiwithopen_in_tabbut ignores thebrowser_stylekey (which was deprecated in Chrome too). Do not rely on the browser injecting its own stylesheet into the options page.
Cross-browser notes
| 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.
Where to go next
Two pages under this guide cover the entry points into the surface you have just registered. Opening the options page programmatically covers runtime.openOptionsPage, focusing an already-open tab instead of duplicating it, and deep-linking to a section. Showing a first-run setup page after install covers the onboarding screen that must appear exactly once per person — not after every auto-update.
Related
- Opening the options page programmatically — entry points, deduplication and deep links.
- Showing a first-run setup page after install — onboarding that fires once.
- Service Worker Fundamentals — how the service worker’s
onChangedlistener must be registered synchronously at the top level. - Extension Popup Architecture — popup state hydration patterns that complement options persistence.
- Content Scripts & DOM Injection — reacting to
onChangedinside injected scripts to apply per-page behaviour governed by options. - chrome.storage.sync API — quota, rate limits, conflict resolution, and the full
onChangedevent shape. - Options Page Layouts — visual design and interaction patterns for the options UI.
- Up to Manifest V3 Architecture & Extension Lifecycle.