Dark Mode in Extension Popups

Ship a light and dark popup that never flashes: resolving the theme before first paint under the extension CSP, storing the choice, and keeping both themes at AA contrast.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

A popup that flashes white before turning dark is worse than one with no dark mode at all, and the usual fix — an inline script in the head — is refused by the extension content security policy. Popups make the problem sharper than web pages do: they are small, they open next to a dark browser chrome, and they last a few seconds, so a flash occupies a meaningful fraction of their visible life. This page is part of Popup Interface Design and covers a theme that is correct on the first frame.

Root cause: the theme decision arrives after the first paint

Three things want to decide the theme: the operating system preference, the user’s explicit choice stored by your extension, and the CSS that has already been applied. On the web the standard fix is a tiny inline script in the head; under the extension policy inline scripts are blocked, so the resolution has to happen in an external file that still runs before the body renders.

Where the flash comes fromThe stylesheet applies a default theme at first paint; a stored dark preference read afterwards repaints the whole popup.popup openssettledHTML parsedstylesheet appliedFirst pai…default th…Stored preference readasync storage callRepaintthe flash …Correct themesettledthe theme must already be decided heretoo late — the user has seen the wrong one
The gap between first paint and the stored preference arriving is the flash — closing it means deciding earlier, not repainting faster.

Step 1. Let CSS handle the operating-system case with no script at all

If the user has expressed no preference of their own, prefers-color-scheme resolves before the first paint with no JavaScript involved. Building the default this way means the flash only ever needs solving for the explicit-choice case.

 1/* popup.css — tokens, defaulting to the OS preference */
 2:root {
 3  --surface: #ffffff;
 4  --ink: #0f172a;
 5  --muted: #475569;
 6  --line: #e2e8f0;
 7  --accent: #2563eb;
 8}
 9
10@media (prefers-color-scheme: dark) {
11  :root {
12    --surface: #0f172a;
13    --ink: #e2e8f0;
14    --muted: #94a3b8;
15    --line: #1e293b;
16    --accent: #60a5fa;
17  }
18}
19
20/* An explicit choice wins over the media query in both directions. */
21:root[data-theme="light"] { --surface: #ffffff; --ink: #0f172a; --muted: #475569; --line: #e2e8f0; --accent: #2563eb; }
22:root[data-theme="dark"]  { --surface: #0f172a; --ink: #e2e8f0; --muted: #94a3b8; --line: #1e293b; --accent: #60a5fa; }
23
24body { background: var(--surface); color: var(--ink); }

Execution context: Popup stylesheet, applied before the first paint. Declaring the explicit-choice selectors after the media query is what makes an override win in both directions — a user who prefers light while their system is dark is otherwise stuck, which is the bug that most token systems ship with. Using custom properties rather than duplicating rules means one place to add a colour later.

Step 2. Apply the stored choice from an external script in the head

An external script in the head is allowed by the policy, and a classic script there is parser-blocking, so it runs before the body is rendered. That is the sanctioned equivalent of the inline trick.

 1<!-- popup.html -->
 2<!doctype html>
 3<html lang="en">
 4  <head>
 5    <meta charset="utf-8">
 6    <link rel="stylesheet" href="popup.css">
 7    <!-- Not deferred, not a module: it must run before the body renders. -->
 8    <script src="theme-early.js"></script>
 9  </head>
10  <body></body>
11</html>
1// theme-early.js — deliberately tiny and synchronous where it can be
2(() => {
3  // chrome.storage is async, so mirror the choice into localStorage, which is not.
4  let stored = null;
5  try { stored = localStorage.getItem("theme"); } catch { stored = null; }
6  if (stored === "dark" || stored === "light") {
7    document.documentElement.setAttribute("data-theme", stored);
8  }
9})();

Execution context: Popup renderer, during head parsing and before the body is laid out. The mirror into localStorage is the crux: chrome.storage is promise-based and therefore always resolves after the first paint, whereas localStorage is synchronous and scoped to the extension origin. chrome.storage.sync remains the source of truth so the choice follows the user across devices; localStorage is a paint-time cache of it, and the two are reconciled a moment later.

Step 3. Reconcile the cache with the real preference

Once the popup is running, read the authoritative value and correct the cache. A mismatch is rare — it happens after the setting was changed on another device — and correcting it silently is the right behaviour.

 1// popup.js
 2const root = document.documentElement;
 3
 4async function reconcileTheme() {
 5  const { theme } = await chrome.storage.sync.get("theme");
 6  const resolved = theme === "dark" || theme === "light" ? theme : null;
 7
 8  if (resolved) {
 9    root.setAttribute("data-theme", resolved);
10    try { localStorage.setItem("theme", resolved); } catch { /* storage blocked */ }
11  } else {
12    root.removeAttribute("data-theme");          // fall back to the OS preference
13    try { localStorage.removeItem("theme"); } catch { /* storage blocked */ }
14  }
15}
16
17export async function setTheme(next) {
18  root.setAttribute("data-theme", next);
19  try { localStorage.setItem("theme", next); } catch { /* storage blocked */ }
20  await chrome.storage.sync.set({ theme: next });   // propagates to every other surface
21}
22
23document.addEventListener("DOMContentLoaded", () => { void reconcileTheme(); });

Execution context: Popup renderer. Writing the attribute before awaiting the storage write means the toggle feels instant while the durable write happens behind it. Because the value is in storage.sync, the options page and side panel receive a storage.onChanged event and can apply the same attribute without any further wiring.

Three sources, one resolved themeThe OS preference is the default, a synced explicit choice overrides it, and a local mirror makes that choice available before the first paint.prefers-color-schemeCSS, no scriptstorage.syncthe user's real choicelocalStorage mirrorsynchronous, paint-timeresolved into one attribute on the root elementdata-themelight, dark or absentCustom propertiesone token set applied
The mirror exists purely for timing — the synced value is what actually decides.

Step 4. Keep both themes at AA contrast

A dark theme built by inverting a light one usually fails contrast somewhere, most often on muted text and on the accent colour used for links. Check the pairs that actually occur rather than the palette in the abstract.

Contrast of each token pair in both themesMeasured contrast ratios for body text, muted text and accent text against their surfaces in the light and dark token sets.Light: ink on surface16.1 : 1#0f172a on #ffffffDark: ink on surface13.4 : 1#e2e8f0 on #0f172aLight: muted on surface8.2 : 1#475569 on #ffffffDark: muted on surface6.1 : 1#94a3b8 on #0f172aLight: accent on surface5.2 : 1#2563eb on #ffffffDark: accent on surface6.9 : 1#60a5fa on #0f172a
Muted text is where dark themes fail — it is the pair worth measuring first, not last.

The accent row is why the dark palette uses a lighter blue rather than the same one: #2563eb on #0f172a measures about 2.4, which is unreadable, and the instinct to keep brand colours constant across themes is exactly what produces that. Brand marks can stay saturated; brand colours used as text cannot.

Step 5. Apply the same resolution to icons and diagrams

Anything that carries its own colours — an inline SVG, a status icon — has to follow the theme too, or it becomes a bright rectangle in a dark popup.

1/* Icons inherit the resolved ink colour instead of carrying their own. */
2.icon { fill: currentColor; color: var(--muted); }
3.icon-accent { color: var(--accent); }
4
5/* Any inline SVG gets an explicit canvas so its labels keep contrast in both themes. */
6svg[role="img"] { background: var(--surface); border-radius: 6px; }

Execution context: Popup stylesheet. fill: currentColor is the single most useful rule here: it makes an icon follow the theme with no per-theme override at all. Giving inline SVGs an explicit background is what stops a diagram drawn for a light surface from sitting on a dark one with unreadable labels — the same reasoning applies to any figure the popup renders.

Cross-browser variation

  • Chrome 120+: prefers-color-scheme in an extension page follows the browser’s own theme setting, which may differ from the operating system.
  • Firefox 121+: follows the OS or the Firefox theme depending on the user’s settings, so a user with a dark Firefox theme on a light system gets dark — which is usually what they want.
  • Safari 17+: follows the system appearance. localStorage is available in extension pages as elsewhere, so the paint-time mirror works identically.
  • All engines: inline scripts are blocked in extension pages, so the external head script is the portable approach. localStorage may be unavailable in some privacy configurations, hence the try/catch.

Verification

  1. Set the extension to dark, close and reopen the popup ten times, and watch for a flash. There should be none.
  2. Change the system theme while the popup is closed and reopen it with no stored choice. The popup should follow the system.
  3. Set an explicit light theme on a dark system and confirm it wins — the override has to work in both directions.
  4. Run an automated contrast check over both themes and confirm every text pair clears 4.5, including muted text and links.

FAQ

Why not read chrome.storage in the head script?

Because it is asynchronous, so its result always arrives after the first paint. That is precisely the flash you are trying to remove — hence the synchronous localStorage mirror.

Is storing the theme twice a problem?

Only if they disagree without being reconciled. Treat chrome.storage.sync as authoritative, the mirror as a cache, and correct the cache on every load — which is what step 3 does.

Do I need a toggle at all?

Following the system alone is a perfectly good product decision and needs no JavaScript. Add the toggle when users ask to differ from their system, and note that it is a maturity-level expectation for most extensions.

What about the browser’s own theme?

Extensions cannot read it directly. prefers-color-scheme is the closest signal, and in Chrome and Firefox it already reflects the browser theme where the user has set one.

Other UI/UX Patterns & Interactive Components Resources