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.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The browser is in dark mode, the popup is dark, and then the user opens the options page and gets a white page with white form controls that flash before the styles load. Or the page is dark, but the checkboxes and scrollbars are still light because the browser was never told the page supports a dark scheme. An options page is usually the least-designed surface in an extension, and theming it properly takes three pieces: design tokens as CSS custom properties, the color-scheme property so native controls match, and a stored theme preference applied before the first paint. This guide builds all three. It belongs to options page layouts.

How theme selection should work

Default to following the operating system through prefers-color-scheme, which needs no JavaScript and updates live when the OS switches. Offer an override — Light, Dark, System — stored in chrome.storage so the popup, side panel and options page all follow the same choice. Apply the override by setting an attribute on <html> that the CSS tokens key off. Because chrome.storage is asynchronous, the attribute can only be set after a short delay; to avoid a flash, mirror the preference into localStorage (synchronous, per extension origin) and apply it from a small blocking script before the stylesheet renders. Declare color-scheme so form controls, scrollbars and the canvas background follow the theme.

Resolving the theme before first paintA tiny blocking script reads the theme override from localStorage and sets data-theme on the html element; CSS tokens resolve from data-theme or prefers-color-scheme; after load, the page syncs with chrome.storage and listens for changes from other contexts.theme-init.jsblocking, in <head>localStorage mirrorsync readhtml[data-theme]light / dark / noneCSS resolves tokensTokens--bg, --text, --accentcolor-schemenative controlsstorage.onChangedlive updates
Synchronous mirror for the first paint; chrome.storage for the truth.

Step-by-step: a themed options page

1. Define tokens for both schemes

 1/* tokens.css — shared by popup, options and side panel */
 2:root {
 3  color-scheme: light dark;
 4  --bg: #ffffff; --surface: #f6f7f9; --text: #1d2330; --muted: #5b6475;
 5  --border: #d9dde5; --accent: #2457d6; --accent-text: #ffffff; --danger: #c62828;
 6}
 7@media (prefers-color-scheme: dark) {
 8  :root:not([data-theme="light"]) {
 9    --bg: #17191f; --surface: #20232b; --text: #e7e9ee; --muted: #a2a9b8;
10    --border: #343946; --accent: #7aa2ff; --accent-text: #0d1117; --danger: #ff6b6b;
11  }
12}
13:root[data-theme="dark"] {
14  color-scheme: dark;
15  --bg: #17191f; --surface: #20232b; --text: #e7e9ee; --muted: #a2a9b8;
16  --border: #343946; --accent: #7aa2ff; --accent-text: #0d1117; --danger: #ff6b6b;
17}
18:root[data-theme="light"] { color-scheme: light; }
19body { background: var(--bg); color: var(--text); }

Execution context: a stylesheet linked by every extension page. Components use only tokens, never raw colours. The dark values appear twice — once for “System” when the OS is dark, once for the explicit “Dark” override — and the :not([data-theme="light"]) guard lets a “Light” override win over a dark OS. Setting color-scheme per override makes native checkboxes, selects and scrollbars match.

2. Apply the override before first paint

1<!-- options.html <head> -->
2<script src="theme-init.js"></script>
3<link rel="stylesheet" href="tokens.css">
1// theme-init.js — tiny, synchronous, no imports
2try {
3  const t = localStorage.getItem("theme");
4  if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
5} catch {}

Execution context: extension pages, loaded as an external script (inline scripts are blocked by the MV3 CSP). localStorage in extension pages is per-extension and synchronous, so the attribute is set before the browser paints. The try guards against storage being unavailable. Without this step, the page paints with the system theme and then switches — a visible flash for users who chose an override.

Override × OS scheme → resultThe resulting theme for each combination of the user's stored override (System, Light, Dark) and the operating system's colour scheme.OverrideOS lightOS darkSystemLightDarkLightLightLightDarkDarkDark
System follows the OS; explicit choices win regardless.

3. Store the choice in chrome.storage and mirror it

 1// theme.js
 2export async function setTheme(theme) {                  // "system" | "light" | "dark"
 3  await chrome.storage.sync.set({ theme });
 4}
 5
 6export function applyTheme(theme) {
 7  const root = document.documentElement;
 8  if (theme === "light" || theme === "dark") root.dataset.theme = theme; else delete root.dataset.theme;
 9  try { theme === "system" ? localStorage.removeItem("theme") : localStorage.setItem("theme", theme); } catch {}
10}
11
12chrome.storage.sync.get({ theme: "system" }).then(({ theme }) => applyTheme(theme));
13chrome.storage.onChanged.addListener((c, area) => { if (area === "sync" && c.theme) applyTheme(c.theme.newValue ?? "system"); });

Execution context: every extension page. chrome.storage.sync is the source of truth, shared by all contexts and devices; localStorage is only a first-paint mirror. Listening for changes switches every open page — options, popup, side panel — the moment the user chooses. Content scripts cannot see the extension’s localStorage, so injected UI reads the theme from chrome.storage.

4. Build the control

1<fieldset class="theme-picker">
2  <legend data-i18n="theme"></legend>
3  <label><input type="radio" name="theme" value="system"> <span data-i18n="themeSystem"></span></label>
4  <label><input type="radio" name="theme" value="light">  <span data-i18n="themeLight"></span></label>
5  <label><input type="radio" name="theme" value="dark">   <span data-i18n="themeDark"></span></label>
6</fieldset>
1const picker = document.querySelector(".theme-picker");
2chrome.storage.sync.get({ theme: "system" }).then(({ theme }) => (picker.querySelector(`[value="${theme}"]`).checked = true));
3picker.addEventListener("change", (e) => setTheme(e.target.value));

Execution context: the options page. A radio group is the accessible control for three mutually exclusive choices. The change takes effect instantly (auto-save is right for a theme), so no Save button is needed — see auto-save vs explicit save in options.

Switching theme from optionsThe user chooses Dark in options; the page writes theme to chrome.storage.sync; onChanged fires in the options page, the open popup and the side panel; each applies data-theme and updates its localStorage mirror.Options pagechrome.storage.syncSide panelset({theme:'dark'})onChanged → applyTheme('dark')onChanged → applyTheme('dark')
One write, every open surface switches.

5. Handle the embedded options frame

When options_ui.open_in_tab is false, Chrome renders the page inside chrome://extensions, which has its own light or dark theme. Give body an explicit background from your tokens so the frame does not show a mismatched colour, and keep borders visible against both. Test with the browser’s own theme set to dark and to light.

6. Check contrast in both themes

Accent colours that pass contrast on white often fail on dark backgrounds, which is why --accent is lighter in the dark token set. Check text, muted text, borders and focus rings against both backgrounds at WCAG AA (4.5:1 for body text, 3:1 for UI components). DevTools’ colour picker shows the contrast ratio.

7. Theme images and icons

1.logo { content: url("img/logo-light.svg"); }
2:root[data-theme="dark"] .logo { content: url("img/logo-dark.svg"); }
3@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .logo { content: url("img/logo-dark.svg"); } }

Execution context: extension page CSS. Inline SVG icons with fill: currentColor follow text colour automatically and need no variants. Raster images and logos may need a dark version. See dark mode in extension popups.

8. Theme injected UI from storage

 1// content script
 2const host = document.createElement("div");
 3const root = host.attachShadow({ mode: "closed" });
 4async function applyInjectedTheme() {
 5  const { theme = "system" } = await chrome.storage.sync.get("theme");
 6  const dark = theme === "dark" || (theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
 7  host.dataset.theme = dark ? "dark" : "light";
 8}
 9applyInjectedTheme();
10chrome.storage.onChanged.addListener((c) => { if (c.theme) applyInjectedTheme(); });
11matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyInjectedTheme);

Execution context: a content script rendering UI into a shadow root. Content scripts run in the page’s origin, so they cannot read the extension’s localStorage mirror, and the page’s own color-scheme may differ from the user’s choice. Resolving the theme from chrome.storage plus the OS media query, and keying the shadow root’s tokens off a data-theme attribute on the host, keeps injected panels consistent with the options page and popup. Listen to both storage and the media query so a change in either switches the overlay live.

Common mistakes

  • No color-scheme. Native controls and scrollbars stay light.
  • Applying the override after load. A flash of the wrong theme.
  • Inline theme script. Blocked by the extension CSP.
  • Theme only in localStorage. Other contexts and devices can’t see it.
  • Same accent colour in both themes. Fails contrast in dark.

Cross-browser variation

  • Chrome / Edge: prefers-color-scheme follows the OS or the browser’s theme setting; color-scheme styles native controls.
  • Firefox: follows the Firefox theme and OS; the add-ons manager embeds options with its own styling.
  • Safari: follows macOS appearance; color-scheme supported.

Verification

  1. With the OS in dark mode and override System, reload options — no white flash.
  2. Choose Light with the OS dark; confirm everything, including checkboxes, is light.
  3. Change the theme in options with the popup open and confirm the popup switches.
  4. Check contrast of text and focus rings in both themes.

FAQ

Should the default be System?

Yes. It respects the user’s existing choice without any action.

Can I read the theme in the service worker?

Read it from chrome.storage; prefers-color-scheme can be queried in the worker with matchMedia in some browsers, but storage is reliable.

Do I need separate CSS files per theme?

No. One file with token overrides is simpler and avoids loading delays.

Should forced colours override my dark theme?

Yes. When forced-colors: active, the browser replaces your palette with the user’s system colours regardless of theme. Keep borders and currentColor icons so the page survives, as described in respecting reduced motion and forced colors.

Other UI/UX Patterns & Interactive Components Resources