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.
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.
storage permission declared in manifest.json — needed before any read or write in the options page script.options_ui key wired in manifest.json; decide now whether you want open_in_tab: true (full tab, full viewport) or the embedded panel. Details in options page configuration.onclick attributes; MV3 CSP rejects them.prefers-color-scheme must be handled explicitly or users get a jarring white flash on dark browsers.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.
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.
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.
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+).
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.
script-src 'self') rejects onclick, onchange, and <script> tags with inline content. All listeners attach via addEventListener in external .js files.chrome.storage.sync caps at 8 KB per item and ~120 writes/minute. Debounce all form writes.localStorage for cross-context state: localStorage is scoped to the extension’s origin but is not observable from the service worker. Use chrome.storage for any state that the service worker or content scripts also need.chrome.storage.onChanged or message passing, not a direct DOM call.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.
onChanged reactivity.open_in_tab, and lifecycle hooks.Implement accessible ARIA tabs in an MV3 options page — keyboard navigation, deep-linking via URL hash, plain JS and React versions, and cross-browser testing.
Two-way bind an MV3 options form to chrome.storage.sync — debounced autosave, onChanged cross-tab reactivity, optimistic UI, and quota error handling.