How to Build Dark Mode for Your Chrome Extension (The Right Way)
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.
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.
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.
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-outon containers - No flash of unstyled content on popup open
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:
- CSS variables for a token-based color system that switches with a single attribute change
- System detection with real-time change watching via
prefers-color-scheme - chrome.storage.sync for persisting user overrides across devices
- 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
Building Accessible Chrome Extensions: Keyboard, Screen Reader, and WCAG Compliance
26% of US adults have disabilities. Make your Chrome extension accessible with focus traps, ARIA, keyboard nav, and WCAG 2.1 AA compliance.
Android Bottom Navigation vs Navigation Drawer: How to Choose the Right Pattern
Bottom nav for 3-5 destinations, drawer for 6+. Material Design 3 guidelines, thumb zones, and real examples from Gmail, Instagram, and Maps.
I Built the Same Chrome Extension With 5 Different Frameworks. Here's What Actually Happened.
WXT vs Plasmo vs CRXJS vs Extension.js vs Bedframe. Real benchmarks, honest opinions, and the framework with 12K stars that's quietly dying.