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.
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.
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.