How to Build Dark Mode for Your Chrome Extension (The Right Way)

AppBooster Team · · 10 min read
Dark themed code editor interface with syntax highlighting

Here’s a number that should make you uncomfortable: 82% of users prefer dark mode.

If your Chrome extension ships without it, you are not missing a nice-to-have. You are actively frustrating the majority of your users every single time they open your popup at 11pm, squinting at a wall of white light like they’ve just walked into the sun.

Dark mode isn’t a feature request anymore. It’s a baseline expectation - right next to “the extension shouldn’t crash” and “it should remember my settings.”

The good news: implementing dark mode correctly is not that hard. The bad news: most tutorials stop at “add a dark class to your body tag” and call it a day. That approach breaks in content scripts, ignores OLED users, and produces the kind of jarring light flash on load that makes users immediately reach for the uninstall button.

This guide covers the full picture - CSS variables, system detection, chrome.storage.sync persistence, and Shadow DOM isolation for content scripts. Let’s build it properly.


Why Dark Mode Matters Beyond Aesthetics

Before diving into code, the numbers worth knowing:

  • 65% of dark mode users cite reduced eye fatigue as their primary reason
  • 42% of OLED device users report measurable battery savings - dark pixels on OLED draw significantly less power than lit pixels
  • Users who prefer dark mode across their OS almost universally expect extensions to respect that preference automatically

The last point is where most extensions fail. Detecting the system preference and honoring it without asking the user to go hunt for a toggle is the difference between a polished extension and one that feels like an afterthought.

Dark mode UI comparison showing reduced eye strain at night


Step 1: Build Your Color System With CSS Variables

The single biggest mistake developers make is hardcoding colors. When dark mode arrives as a last-minute addition, you end up grepping through hundreds of #ffffff and background: white instances and hoping you caught them all. You never do.

Start with a token-based system from day one:

/* popup.css */

:root {
  /* Light theme (default) */
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f5;
  --bg-elevated: #ffffff;

  --text-primary: #1a1a1a;
  --text-secondary: #6b7280;
  --text-muted: #9ca3af;

  --border-color: #e5e7eb;
  --border-subtle: #f3f4f6;

  --accent-primary: #3b82f6;
  --accent-hover: #2563eb;

  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
}

/* Dark theme */
[data-theme="dark"] {
  --bg-primary: #121212;      /* NOT #000000 - see note below */
  --bg-secondary: #1e1e1e;
  --bg-elevated: #2a2a2a;

  --text-primary: #e8e8e8;
  --text-secondary: #a0a0a0;
  --text-muted: #6b6b6b;

  --border-color: #2e2e2e;
  --border-subtle: #1e1e1e;

  --accent-primary: #60a5fa;
  --accent-hover: #93c5fd;

  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
}

The #121212 rule: Pure black (#000000) causes a phenomenon called “halation” on OLED screens where high-contrast edges between black and white pixels create a bloom effect. Material Design has used #121212 as their recommended dark surface color since 2019. Use it.

Now every component just references these tokens:

.popup-container {
  background: var(--bg-primary);
  color: var(--text-primary);
  border: 1px solid var(--border-color);
}

.button-primary {
  background: var(--accent-primary);
  color: #ffffff;
  transition: background-color 300ms ease-in-out;
}

.button-primary:hover {
  background: var(--accent-hover);
}

That transition: background-color 300ms ease-in-out on interactive elements is the detail that separates smooth theme switching from the jarring flash that makes users think your extension glitched.


Step 2: Detect the System Preference

Your extension should respect whatever the user has set at the OS level. No toggle required, no setting to configure - it just works.

// utils/theme.js

export function getSystemTheme() {
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

export function watchSystemTheme(callback) {
  const query = window.matchMedia('(prefers-color-scheme: dark)');

  // Call immediately with current state
  callback(query.matches ? 'dark' : 'light');

  // Watch for changes
  query.addEventListener('change', (e) => {
    callback(e.matches ? 'dark' : 'light');
  });

  // Return cleanup function
  return () => query.removeEventListener('change', callback);
}

The addEventListener('change') call means your extension responds in real time when a user switches their OS from light to dark mode - including when they toggle macOS’s automatic schedule at sunset. Most extensions miss this.

Code editor showing JavaScript theme detection with media query


Step 3: Persist the User’s Choice With chrome.storage.sync

System detection is the default. But users should be able to override it. A “follow system / always dark / always light” three-way toggle covers every use case.

// utils/theme-storage.js

const THEME_KEY = 'userThemePreference';

export const ThemePreference = {
  SYSTEM: 'system',
  DARK: 'dark',
  LIGHT: 'light',
};

export async function getThemePreference() {
  return new Promise((resolve) => {
    chrome.storage.sync.get(THEME_KEY, (result) => {
      resolve(result[THEME_KEY] ?? ThemePreference.SYSTEM);
    });
  });
}

export async function setThemePreference(preference) {
  return new Promise((resolve) => {
    chrome.storage.sync.set({ [THEME_KEY]: preference }, resolve);
  });
}

export function onThemePreferenceChanged(callback) {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'sync' && changes[THEME_KEY]) {
      callback(changes[THEME_KEY].newValue);
    }
  });
}

chrome.storage.sync is the right choice here - not localStorage, not chrome.storage.local. The sync namespace means the user’s theme preference follows them across devices. Open your extension on their work laptop and it’s already in dark mode because they set it that way on their home machine.


Step 4: Wire It All Together in Your Popup

// popup.js

import { getSystemTheme, watchSystemTheme } from './utils/theme.js';
import {
  getThemePreference,
  setThemePreference,
  ThemePreference,
} from './utils/theme-storage.js';

let systemTheme = getSystemTheme();
let cleanupSystemWatcher = null;

async function applyTheme() {
  const preference = await getThemePreference();

  const resolvedTheme =
    preference === ThemePreference.SYSTEM ? systemTheme : preference;

  document.documentElement.setAttribute('data-theme', resolvedTheme);
}

async function initTheme() {
  await applyTheme();

  // Watch for OS-level changes
  cleanupSystemWatcher = watchSystemTheme((newSystemTheme) => {
    systemTheme = newSystemTheme;
    applyTheme();
  });
}

// Theme toggle UI
document.querySelectorAll('[data-theme-option]').forEach((button) => {
  button.addEventListener('click', async () => {
    const preference = button.dataset.themeOption;
    await setThemePreference(preference);
    await applyTheme();

    // Update active state on buttons
    document.querySelectorAll('[data-theme-option]').forEach((btn) => {
      btn.classList.toggle('active', btn === button);
    });
  });
});

document.addEventListener('DOMContentLoaded', initTheme);

Your HTML toggle is straightforward:

<div class="theme-switcher" role="group" aria-label="Theme preference">
  <button data-theme-option="light" aria-label="Light mode">
    ☀️ Light
  </button>
  <button data-theme-option="system" aria-label="Follow system">
    ⚙️ System
  </button>
  <button data-theme-option="dark" aria-label="Dark mode">
    🌙 Dark
  </button>
</div>

Step 5: The Hard Part - Content Scripts and Shadow DOM

Everything above handles your popup cleanly. Content scripts are a different beast entirely.

Content scripts run in the context of the host page. Your CSS can clash with the page’s CSS. The host page’s global styles can bleed into your injected UI. An aggressive reset stylesheet on the target page can strip all your styling. This is where most “dark mode” implementations fall apart completely.

The solution: Shadow DOM.

// content-script.js

class ExtensionWidget {
  constructor() {
    this.host = null;
    this.shadowRoot = null;
    this.init();
  }

  init() {
    // Create an isolated host element
    this.host = document.createElement('div');
    this.host.id = 'my-extension-root';

    // Attach shadow DOM - 'closed' mode for better isolation
    this.shadowRoot = this.host.attachShadow({ mode: 'closed' });

    document.body.appendChild(this.host);
    this.render();
    this.applyTheme();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        /* Styles inside Shadow DOM are fully isolated */
        :host {
          all: initial;
          display: block;
          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
        }

        :host([data-theme="dark"]) {
          --bg-primary: #121212;
          --text-primary: #e8e8e8;
          --border-color: #2e2e2e;
        }

        :host([data-theme="light"]) {
          --bg-primary: #ffffff;
          --text-primary: #1a1a1a;
          --border-color: #e5e7eb;
        }

        .widget-container {
          background: var(--bg-primary);
          color: var(--text-primary);
          border: 1px solid var(--border-color);
          border-radius: 8px;
          padding: 16px;
          transition: background-color 300ms ease-in-out,
                      color 300ms ease-in-out;
        }
      </style>

      <div class="widget-container">
        <!-- Your content script UI here -->
      </div>
    `;
  }

  async applyTheme() {
    const preference = await getThemePreference();
    const systemTheme = getSystemTheme();

    const resolvedTheme =
      preference === ThemePreference.SYSTEM ? systemTheme : preference;

    this.host.setAttribute('data-theme', resolvedTheme);
  }
}

new ExtensionWidget();

The :host selector inside Shadow DOM lets you target the host element from within the shadow tree. Setting data-theme on the host and reading it with :host([data-theme="dark"]) gives you a clean theming hook that’s fully isolated from anything the page does.

If Shadow DOM isn’t an option for your use case (some complex injection scenarios), use aggressive CSS namespacing as a fallback: prefix every class with your extension ID (#ext-abc123 .container) and use all: revert to escape inherited host-page styles.

Chrome DevTools showing Shadow DOM structure in the elements panel


WCAG Contrast: The Part Everyone Skips

Dark mode done wrong creates the same accessibility problems it’s supposed to solve. Low contrast dark UIs are harder to read than light ones, not easier.

The targets:

  • 4.5:1 contrast ratio for normal text (WCAG AA)
  • 3:1 contrast ratio for large text (18px+ regular or 14px+ bold)
  • 3:1 contrast ratio for UI components and focus indicators

For your #121212 background against #e8e8e8 text, the contrast ratio is approximately 10.7:1 - well above AA. For secondary text at #a0a0a0 against #121212, you’re at 4.6:1 - still passing, but close to the floor. Don’t go much lighter than that for secondary text.

Use the WebAIM contrast checker during development, not as an afterthought. Your dark theme color palette should be validated before you ship, not after a user files an accessibility complaint.


Preventing the Flash of Unstyled Content

One more issue that breaks the experience: if you load your preferred theme asynchronously, users see a white flash before the dark theme applies. It’s jarring and it feels broken.

For popups, solve this by setting a blocking theme check in the <head> before any content renders:

<!DOCTYPE html>
<html>
<head>
  <script>
    // Runs synchronously before rendering - no FOUC
    (async function() {
      const result = await chrome.storage.sync.get('userThemePreference');
      const preference = result.userThemePreference ?? 'system';

      let theme = preference;
      if (preference === 'system') {
        theme = window.matchMedia('(prefers-color-scheme: dark)').matches
          ? 'dark'
          : 'light';
      }

      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <!-- content -->
</body>
</html>

The async IIFE in the <head> resolves before the browser paints - no flash, no jarring transition, just the correct theme appearing immediately.


Putting It All Together: The Checklist

Before you ship dark mode, verify each of these:

  • CSS variables defined for both themes - no hardcoded color values anywhere
  • #121212 (not #000000) for primary dark backgrounds
  • System preference detected via prefers-color-scheme
  • System changes watched in real time with addEventListener('change')
  • User preference stored in chrome.storage.sync (not localStorage)
  • Three-way toggle: System / Light / Dark
  • Content scripts using Shadow DOM for style isolation
  • WCAG AA contrast ratios verified for all text/background combinations
  • transition: background-color 300ms ease-in-out on containers
  • No flash of unstyled content on popup open

Chrome extension popup showing clean dark mode interface


Beyond Dark Mode: Understanding How Users Find Your Extension

Dark mode is one piece of the puzzle. The other piece is making sure the users who want an extension like yours can actually find it. User ratings, review velocity, and keyword optimization in the Chrome Web Store all determine whether you show up when someone searches for what you built.

If you’re not tracking your extension’s store performance, ExtensionBooster gives you visibility into ratings, review trends, and competitive positioning - the data layer most extension developers completely ignore until their growth plateaus.


The Bottom Line

Dark mode is a four-part implementation:

  1. CSS variables for a token-based color system that switches with a single attribute change
  2. System detection with real-time change watching via prefers-color-scheme
  3. chrome.storage.sync for persisting user overrides across devices
  4. Shadow DOM for content scripts that need true style isolation

The developers who skip any of these steps end up with dark mode that only works in the popup, or breaks on certain sites, or forgets the user’s preference on every browser restart. The developers who implement all four ship something that feels native - the kind of quality that earns five-star reviews.

Your users are already expecting it. The only question is whether you’re delivering it correctly.


Have questions about implementing dark mode in a specific extension architecture? Common edge cases include iframes, dynamic content injection, and manifest V3 service worker limitations. Drop your scenario in the comments.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles