Options Page Layouts
Design scalable options page UIs for MV3 extensions — sidebar nav, tabbed sections, responsive grids, form components, autosave UX, and dark mode support.
Without a deliberate layout strategy, options pages devolve into a single scrolling column of unrelated inputs — users cannot find settings, forms submit stale data, and adding a new preference means touching three files. This guide covers the layout patterns that scale: sidebar navigation, tabbed sections, and responsive grids, plus the form and save UX that keeps them coherent across browsers. It is part of the UI/UX Patterns & Interactive Components guide. The manifest registration counterpart — declaring options_ui, controlling open_in_tab, and registering the page — lives in options page configuration.
The constraint that shapes every decision here: options pages run in a standard browser tab context, not a popup. You have the full viewport, full DOM, and no 30-second eviction, but you still cannot block the main thread during storage hydration or attach inline event handlers (CSP blocks them). Every interactive element must be wired via addEventListener in an external script.
Prerequisites checklist
storagepermission declared inmanifest.json— needed before any read or write in the options page script.options_uikey wired inmanifest.json; decide now whether you wantopen_in_tab: true(full tab, full viewport) or the embedded panel. Details in options page configuration.- External JS only — no inline
onclickattributes; MV3 CSP rejects them. - Versioned storage schema — options page data outlives releases. Design a migration path from day one.
- Dark-mode CSS — browser-extension UIs default to the browser’s colour scheme, so
prefers-color-schememust be handled explicitly or users get a jarring white flash on dark browsers.
1. Sidebar navigation pattern
A fixed sidebar works best when the options page has four or more distinct sections that users navigate between non-linearly. It leaves the full horizontal width for the content area and makes the active section immediately visible.
The critical constraint: the sidebar must communicate section state through the URL hash, not through JavaScript variables alone. A page reload must restore the active section — if it does not, users who bookmark a deep section or share the URL land on the wrong panel.
1<!-- options.html — sidebar shell (abridged) -->
2<div class="opts-shell">
3 <nav class="opts-sidebar" aria-label="Settings sections">
4 <ul role="list">
5 <li><a href="#general" class="opts-navlink" aria-current="page">General</a></li>
6 <li><a href="#privacy" class="opts-navlink">Privacy</a></li>
7 <li><a href="#appearance" class="opts-navlink">Appearance</a></li>
8 <li><a href="#advanced" class="opts-navlink">Advanced</a></li>
9 </ul>
10 </nav>
11 <main class="opts-content" id="opts-main">
12 <section id="general" class="opts-panel" aria-labelledby="general-heading">
13 <h2 id="general-heading">General</h2>
14 <!-- form fields -->
15 </section>
16 <section id="privacy" class="opts-panel" hidden aria-labelledby="privacy-heading">
17 <h2 id="privacy-heading">Privacy</h2>
18 </section>
19 </main>
20</div>
Execution context: Renders in a standard browser tab (or the embedded options panel if open_in_tab is omitted). Inline scripts are blocked by MV3 CSP — attach all nav behaviour in options.js loaded via <script src="options.js" type="module">.
1// options.js — hash-driven sidebar activation
2function activateSection(hash) {
3 const id = hash.replace('#', '') || 'general';
4 document.querySelectorAll('.opts-panel').forEach(panel => {
5 panel.hidden = panel.id !== id;
6 });
7 document.querySelectorAll('.opts-navlink').forEach(link => {
8 const active = link.getAttribute('href') === `#${id}`;
9 link.setAttribute('aria-current', active ? 'page' : 'false');
10 });
11}
12
13window.addEventListener('hashchange', () => activateSection(location.hash));
14activateSection(location.hash); // restore on load / reload
Execution context: Runs in the options page renderer process (the same tab). location.hash is available immediately; no extension API needed for routing. Chrome, Firefox, and Safari all fire hashchange on the options tab.
2. Tabbed section pattern
Tabs make sense when one section dominates usage and sub-topics are closely related — a “Notifications” panel with tabs for Email, Push, and In-App alerts, for example. The full-width tab bar announces the sub-topics upfront without requiring sidebar space.
Do not use <div> tabs. The building a tabbed options page layout guide walks through the complete ARIA tab pattern, keyboard navigation, and deep-linking via hash. The minimal wiring looks like this:
1<!-- Tab bar -->
2<div role="tablist" aria-label="Notification settings" class="opts-tablist">
3 <button role="tab" aria-selected="true" aria-controls="panel-email" id="tab-email">
4 Email
5 </button>
6 <button role="tab" aria-selected="false" aria-controls="panel-push" id="tab-push" tabindex="-1">
7 Push
8 </button>
9</div>
10<div role="tabpanel" id="panel-email" aria-labelledby="tab-email">
11 <!-- email notification fields -->
12</div>
13<div role="tabpanel" id="panel-push" aria-labelledby="tab-push" hidden>
14 <!-- push fields -->
15</div>
Execution context: Options page renderer. role="tablist" + role="tab" + role="tabpanel" is the only ARIA pattern that screen readers announce as a tab component. Chrome, Firefox, and Safari all support it; test with NVDA and VoiceOver.
3. Responsive grid for settings rows
Individual setting rows — a label, a control, and optional helper text — benefit from a two-column grid that aligns labels on the left with controls on the right. Below 600 px the grid collapses to a single column so the options page stays usable on narrow viewport extensions.
1/* options.css */
2.opts-shell {
3 display: grid;
4 grid-template-columns: 200px 1fr;
5 min-height: 100vh;
6 color-scheme: light dark; /* honour OS preference for scrollbars and system colours */
7}
8
9.opts-sidebar {
10 position: sticky;
11 top: 0;
12 height: 100vh;
13 overflow-y: auto;
14 padding: 1.5rem 1rem;
15 border-right: 1px solid color-mix(in srgb, currentColor 12%, transparent);
16}
17
18.opts-content {
19 padding: 2rem;
20 max-width: 720px;
21}
22
23.opts-row {
24 display: grid;
25 grid-template-columns: 1fr auto;
26 align-items: center;
27 gap: 1rem;
28 padding: 0.75rem 0;
29 border-bottom: 1px solid color-mix(in srgb, currentColor 8%, transparent);
30}
31
32@media (max-width: 600px) {
33 .opts-shell {
34 grid-template-columns: 1fr;
35 }
36 .opts-sidebar {
37 position: static;
38 height: auto;
39 border-right: none;
40 border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
41 }
42 .opts-row {
43 grid-template-columns: 1fr;
44 }
45}
Execution context: CSS loaded from a file in the extension package (<link rel="stylesheet" href="options.css">). Remote stylesheets are blocked by MV3 CSP unless you add an explicit content_security_policy exception — avoid them. color-mix() is supported in Chrome 111+, Firefox 113+, and Safari 16.2+; add a fallback border-color if you target earlier versions.
4. Form components and save UX
Options forms have two failure modes. The first is saving on every keystroke and hitting the chrome.storage.sync write-rate limit (~120 writes/minute). The second is requiring an explicit Save button and then silently discarding changes if the user closes the tab. The right pattern is debounced autosave with visible feedback.
The full two-way binding implementation — reading on mount, writing on change, reacting to onChanged in other tabs — is covered in syncing options form state with chrome.storage. The save-bar pattern shown here gives users a clear confirmation without a blocking modal:
1// save-bar.js — autosave with status indicator
2const statusEl = document.getElementById('opts-save-status');
3
4function showStatus(msg, isError = false) {
5 statusEl.textContent = msg;
6 statusEl.className = isError ? 'opts-status opts-status--error' : 'opts-status opts-status--ok';
7 statusEl.hidden = false;
8 setTimeout(() => { statusEl.hidden = true; }, 2500);
9}
10
11let saveTimer = null;
12
13export function scheduleSave(key, value) {
14 clearTimeout(saveTimer);
15 saveTimer = setTimeout(async () => {
16 try {
17 await chrome.storage.sync.set({ [key]: value });
18 showStatus('Saved');
19 } catch (err) {
20 showStatus('Save failed — storage quota exceeded', true);
21 }
22 }, 400); // 400 ms debounce keeps writes well under the rate limit
23}
Execution context: Options page renderer. chrome.storage.sync.set returns a Promise in MV3 — no callback form needed. The 400 ms debounce is deliberately longer than the 300 ms UI interaction debounce to stay safely below the per-minute write cap. Firefox and Safari honour the same chrome.storage.sync surface (via the WebExtensions polyfill for Firefox or native on Safari 15.4+).
5. Dark mode support
Extension pages inherit the browser’s preferred colour scheme but do not automatically apply it to custom styles. Failing to handle dark mode means a glaring white options page inside a dark-themed browser — the most common visual complaint in extension reviews.
1/* Prefer @media over JS for zero-flash dark support */
2:root {
3 --opts-bg: #ffffff;
4 --opts-surface: #f5f5f5;
5 --opts-text: #111111;
6 --opts-accent: #2563eb;
7 --opts-border: rgba(0, 0, 0, 0.10);
8}
9
10@media (prefers-color-scheme: dark) {
11 :root {
12 --opts-bg: #1a1a1a;
13 --opts-surface: #242424;
14 --opts-text: #ededed;
15 --opts-accent: #60a5fa;
16 --opts-border: rgba(255, 255, 255, 0.10);
17 }
18}
19
20body {
21 background: var(--opts-bg);
22 color: var(--opts-text);
23}
24
25/* User-selectable theme override stored in chrome.storage.sync */
26[data-theme="dark"] {
27 --opts-bg: #1a1a1a;
28 --opts-surface: #242424;
29 --opts-text: #ededed;
30 --opts-accent: #60a5fa;
31 --opts-border: rgba(255, 255, 255, 0.10);
32}
33
34[data-theme="light"] {
35 --opts-bg: #ffffff;
36 --opts-surface: #f5f5f5;
37 --opts-text: #111111;
38 --opts-accent: #2563eb;
39 --opts-border: rgba(0, 0, 0, 0.10);
40}
Execution context: Static CSS file loaded in options.html. Apply the data-theme attribute to <html> immediately after reading from chrome.storage.sync — before DOMContentLoaded if possible — to avoid a flash of wrong theme. Chrome, Firefox, and Safari all respect prefers-color-scheme; the color-scheme property on :root additionally adjusts system UI elements like scrollbars and input borders.
6. Organising settings around tasks, not implementation
The most common structural problem in extension options pages is that they mirror the code rather than the user. Settings appear in the order they were added, grouped by the module that reads them, labelled with the names developers use internally. The result is a page that makes sense to the person who wrote it and to nobody else.
A better organisation starts from what users come to the options page to do. For most extensions that is a short list: change how the extension looks, choose where it runs, control what it does automatically, manage their account or data, and get help. Each of those becomes a section, and each setting goes into the section that matches the task a user would be thinking of when they want to change it — not the module it happens to live in.
Within a section, order settings by how often they are changed. The one setting most users adjust goes first, visible without scrolling; rarely used options go further down or behind an “Advanced” disclosure. Labels describe the outcome rather than the mechanism: “Save pages automatically after a minute of reading” says what happens, where “Auto-capture threshold” says how it is implemented.
7. Settings that affect other surfaces
Many settings change something the user cannot see from the options page: the popup’s layout, the badge, what happens on a site. When the effect is invisible, users are left unsure whether a change worked. Close the loop wherever possible. A small live preview beside visual settings, a sentence describing the current behaviour beside behavioural ones, and a confirmation that names the effect — “Badge now shows unread count” rather than “Saved” — all help. Where the change only takes effect on the next page load, say so, and offer to reload the current tab.
These settings must also propagate. The options page writes to storage; every other surface — the popup, side panel, content scripts and the service worker — should react through storage.onChanged rather than reading once at startup, so a change applies everywhere immediately, as described in syncing options form state with chrome.storage.
8. Destructive and advanced actions
Some options page actions cannot be undone: clearing saved data, resetting all settings, revoking access, signing out everywhere. They belong at the bottom of the page, visually separated, with plain language about the consequence and a confirmation step that names exactly what will be lost. A native <dialog> element works well for this in extension pages and is accessible by default.
Where it is cheap, make destructive actions reversible instead. Keeping a local backup of settings before a reset, or moving deleted items to a recoverable state for a few days, turns an irreversible action into a safe one and removes the need for alarming confirmation copy. The validation and reset patterns are covered in validating and resetting options forms.
9. Growing the page without losing users
Options pages grow with the product. What starts as five toggles becomes fifty settings across several sections, and the layout that worked at five stops working. Plan for the growth in stages. At a dozen settings, add section headings and a table of contents. At a few dozen, add search that matches labels, help text and synonyms, as described in search and filter in a large options page. Beyond that, consider whether some settings belong elsewhere — per-site options in the popup, where the user already is when they want to change them, and power-user options behind an explicit advanced mode. The aim throughout is that the setting a user wants is never more than a glance or a search away, however large the page becomes.
10. Testing the page with real settings
An options page is usually designed and tested with default values, which is the one state most long-term users are never in. Test it with a realistic, heavily customised profile instead: dozens of enabled sites, long custom labels, every optional feature switched on, a language with long words, and a narrow window. Those conditions expose overflowing rows, lists that need scrolling within scrolling, labels that wrap under their controls, and save indicators that appear far from the setting they refer to. A fixture that seeds such a profile into storage makes the check repeatable, and running it at both the embedded options width and a full tab width covers the two ways the page is actually displayed.
Repeat the check with a screen reader and with the keyboard alone, because a crowded options page is also where focus order most often drifts away from the visual order. Fixing both at the same time, against the same realistic profile, is far cheaper than discovering each problem separately from user reports after release.
MV3 constraints to design around
- No inline scripts: MV3 CSP (
script-src 'self') rejectsonclick,onchange, and<script>tags with inline content. All listeners attach viaaddEventListenerin external.jsfiles. - No remote resources: remote stylesheets and scripts are blocked. Bundle everything in the extension package.
- Storage quota:
chrome.storage.synccaps at 8 KB per item and ~120 writes/minute. Debounce all form writes. - No
localStoragefor cross-context state:localStorageis scoped to the extension’s origin but is not observable from the service worker. Usechrome.storagefor any state that the service worker or content scripts also need. - Options page is not always open: never assume the options page is listening. The service worker must use
chrome.storage.onChangedor message passing, not a direct DOM call.
Cross-browser notes
Chrome and Edge: full support for open_in_tab, chrome.storage.sync, and chrome.runtime.openOptionsPage(). Automatic conflict resolution for sync writes across signed-in profiles.
Firefox: browser.runtime.openOptionsPage() is the native call; chrome.runtime.openOptionsPage() works only with the webextension-polyfill. Firefox 109+ ships MV3 but still ships some MV2 APIs in parallel. The embedded panel (without open_in_tab) is poorly supported in Firefox — prefer open_in_tab: true for widest compatibility.
Safari: options pages work in Safari 15.4+ with WebKit’s MV3 implementation. prefers-color-scheme works, but color-mix() requires Safari 16.2+. Safari’s sync backend maps to iCloud, with stricter per-extension quotas and potential throttling on cellular connections. Test save flows under network constraints.
Further guides in this topic
The guides below go deeper into specific options page layouts problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- Accessible Form Controls for Extension Settings — Build extension settings that work with a keyboard and a screen reader — labelled toggles, grouped radios, custom switches done properly, inline validation and save feedback that is announced.
- Search and Filter in a Large Options Page — Add search to an extension options page with dozens of settings — an index built from the DOM, highlighting matches, keeping sections navigable, and announcing results accessibly.
- Auto-Save vs Explicit Save in Options — Decide between auto-saving extension settings and a Save button: how each feels, chrome.storage.sync write limits, saved-state feedback, validation, unsaved-changes warnings, and a hybrid that suits most extensions.
- Building an Options Page with React — Build an MV3 extension options page in React: a typed settings hook backed by chrome.storage, live updates from other contexts, forms with validation, embedding in chrome://extensions, CSP-compatible builds, and testing.
- 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.
- Showing Permission Status on the Options Page — Show users which permissions and site access an MV3 extension has: listing granted and optional permissions with chrome.permissions, requesting and removing them from options, explaining host access, and reacting to changes made elsewhere.
- Theming the Options Page for Dark Mode — Give an extension’s options page light, dark and system themes: CSS custom properties, prefers-color-scheme, color-scheme for native controls, a stored theme override applied before first paint, the embedded options frame, and sharing tokens with the popup.
Related
- Building a tabbed options page layout — ARIA tabs, keyboard navigation, and deep-linking via hash.
- Syncing options form state with chrome.storage — two-way binding, debounced autosave, and
onChangedreactivity. - Options page configuration — manifest registration,
open_in_tab, and lifecycle hooks. - Chrome Storage API & Sync — quota limits, async patterns, and cross-browser adapters.
- Internationalization & Accessibility — the translation pass and keyboard contract every form here needs.
- Up to UI/UX Patterns & Interactive Components.