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.
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.
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.
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.
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-schemefollows the OS or the browser’s theme setting;color-schemestyles native controls. - Firefox: follows the Firefox theme and OS; the add-ons manager embeds options with its own styling.
- Safari: follows macOS appearance;
color-schemesupported.
Verification
- With the OS in dark mode and override System, reload options — no white flash.
- Choose Light with the OS dark; confirm everything, including checkboxes, is light.
- Change the theme in options with the popup open and confirm the popup switches.
- 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.
Related
- Dark mode in extension popups — the popup side.
- Respecting reduced motion and forced colors — other display preferences.
- Reacting to option changes in every context — live updates.
- Options page layouts — the parent topic.