Implementing Dark Mode for Extension UI
Build a polished dark mode for your Chrome extension with CSS custom properties, OS sync, smooth transitions, and accessible contrast ratios. Complete implementation guide.
Table of Contents
Implementing Dark Mode for Extension UI
CSS custom properties, OS sync, smooth transitions, and accessible contrast — the complete dark mode playbook for extensions.
Dark mode is not optional anymore. Over 80% of users enable it when available, and extensions that ignore dark mode feel broken — especially when the rest of the browser wears a dark coat. But slapping filter: invert(1) on your popup is not dark mode. It is a hack that inverts images, breaks brand colors, and makes your extension look like a photographic negative.
Real dark mode means designing intentional color palettes, respecting user preferences across multiple signals, persisting choices correctly, and ensuring every interactive element meets WCAG contrast requirements in both themes. This guide walks through the full implementation, from CSS architecture to testing.
Why CSS Custom Properties Win#
There are three primary approaches to implementing dark mode in a Chrome extension. Each has tradeoffs around performance, flexibility, and maintainability.
The winning strategy combines all three: detect the OS preference as the default, allow manual override, and implement everything through CSS custom properties so the actual color switching is pure CSS.
Setting Up the Token System#
Start by defining your color tokens. Do not just create --background and --text — you need a full semantic token system that covers surfaces, borders, interactive states, and feedback colors.
/* tokens.css — your design system foundation */
:root {
/* Surface hierarchy */
--surface-primary: #ffffff;
--surface-secondary: #f8f9fa;
--surface-tertiary: #f1f3f5;
--surface-elevated: #ffffff;
/* Text hierarchy */
--text-primary: #1a1a2e;
--text-secondary: #495057;
--text-tertiary: #868e96;
--text-inverse: #ffffff;
/* Borders */
--border-default: #dee2e6;
--border-subtle: #e9ecef;
--border-strong: #adb5bd;
/* Interactive */
--interactive-primary: #4263eb;
--interactive-hover: #3b5bdb;
--interactive-active: #364fc7;
--interactive-muted: #dbe4ff;
/* Feedback */
--feedback-success: #2b8a3e;
--feedback-warning: #e67700;
--feedback-error: #c92a2a;
--feedback-info: #1971c2;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/* Transition */
--theme-transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
/* Dark overrides */
[data-theme="dark"] {
--surface-primary: #1a1b26;
--surface-secondary: #24253a;
--surface-tertiary: #2e304a;
--surface-elevated: #2e304a;
--text-primary: #e1e2e8;
--text-secondary: #a9b1d6;
--text-tertiary: #737aa2;
--text-inverse: #1a1b26;
--border-default: #3b3d57;
--border-subtle: #2e304a;
--border-strong: #565a7e;
--interactive-primary: #7aa2f7;
--interactive-hover: #89b4fa;
--interactive-active: #6d8fd4;
--interactive-muted: #292e42;
--feedback-success: #9ece6a;
--feedback-warning: #e0af68;
--feedback-error: #f7768e;
--feedback-info: #7dcfff;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
}Notice the dark palette is not a mathematical inversion. Pure black backgrounds (#000000) cause halation — a visual bleed effect where white text appears to blur against pure black. The dark surface starts at #1a1b26, a deep navy-charcoal that is easier on the eyes.
The Theme Controller#
The TypeScript controller handles detection, persistence, and application. It needs to manage three states: light, dark, and system (auto-detect).
type Theme = "light" | "dark" | "system";
interface ThemeState {
preference: Theme; // What the user chose
resolved: "light" | "dark"; // What is actually applied
}
class ThemeController {
private mediaQuery: MediaQueryList;
private listeners: Set<(state: ThemeState) => void> = new Set();
constructor() {
this.mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.mediaQuery.addEventListener("change", () => this.apply());
this.apply();
}
async getPreference(): Promise<Theme> {
const { theme } = await chrome.storage.local.get({ theme: "system" });
return theme as Theme;
}
async setPreference(theme: Theme): Promise<void> {
await chrome.storage.local.set({ theme });
this.apply();
}
private resolveTheme(preference: Theme): "light" | "dark" {
if (preference === "system") {
return this.mediaQuery.matches ? "dark" : "light";
}
return preference;
}
async apply(): Promise<void> {
const preference = await this.getPreference();
const resolved = this.resolveTheme(preference);
document.documentElement.setAttribute("data-theme", resolved);
const state: ThemeState = { preference, resolved };
this.listeners.forEach((fn) => fn(state));
}
onChange(callback: (state: ThemeState) => void): () => void {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
}
// Initialize early — ideally in a <script> in <head> to prevent flash
const themeController = new ThemeController();Define Semantic Tokens
Create CSS custom properties for every color in your design system — surfaces, text, borders, interactive states, and feedback colors. Never hard-code hex values in component styles.
Override for Dark Theme
Define the dark palette under [data-theme='dark']. Don't invert — redesign. Dark backgrounds should be deep gray or navy, not pure black.
Detect OS Preference
Use matchMedia('(prefers-color-scheme: dark)') to read the OS setting. Listen for changes so switching the OS theme updates your extension instantly.
Persist User Choice
Store the explicit preference (light/dark/system) in chrome.storage.local. 'System' means auto-detect, so always store the intent, not the resolved value.
Apply Before Paint
Set data-theme on documentElement as early as possible — ideally in a blocking script in <head>. This prevents the flash of wrong theme that ruins the experience.
Transition Smoothly
Add CSS transitions on background-color, color, and border-color. Keep them short (200ms) so theme switching feels instant but not jarring.
Preventing Flash of Wrong Theme#
The most common dark mode bug is FOWT — Flash of Wrong Theme. The popup loads in light mode, then JavaScript runs and switches to dark. For a split second the user sees a blinding white flash. In a browser popup, this is extremely noticeable.
The fix is to apply the theme before any rendering happens. In your popup HTML, add a blocking script in the <head>:
<!DOCTYPE html>
<html>
<head>
<script>
// Blocking script — runs before any paint
(async () => {
try {
const { theme } = await chrome.storage.local.get({ theme: "system" });
let resolved = theme;
if (theme === "system") {
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
document.documentElement.setAttribute("data-theme", resolved);
} catch {
// Fallback: check OS preference synchronously
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.setAttribute("data-theme", "dark");
}
}
})();
</script>
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="popup.css" />
</head>The chrome.storage.local.get() call is fast enough to complete before the first paint in most cases. The catch block ensures that even if storage fails, the OS preference still applies.
| Feature | Strategy | FOWT Prevention | User Override | OS Sync | Complexity |
|---|---|---|---|---|---|
| CSS-only media query | ✓ Perfect | ✗ None | ✓ Automatic | Low | |
| JS class toggle (body onload) | ✗ Flash visible | ✓ Full | ✗ Manual | Low | |
| Blocking head script + storage | ✓ Near-perfect | ✓ Full | ✓ Automatic | Medium | |
| Inline style in manifest | ✗ Not possible | ✗ N/A | ✗ N/A | N/A |
Color Choices That Work#
Choosing dark mode colors is not about making everything dark. It is about maintaining readability, hierarchy, and visual comfort.
- Use a dark gray (#1a1b26 to #2d2d3f) as your primary background — never pure black
- Maintain at least 4.5:1 contrast ratio for normal text and 3:1 for large text
- Reduce white text opacity slightly (#e1e2e8 instead of #ffffff) to soften eye strain
- Dim vibrant colors by 10-15% in dark mode — saturated colors glow too much on dark backgrounds
- Test with the extension popup actually open in the browser, not just in a standalone page
- Use subtle elevation (lighter surfaces) instead of shadows to indicate depth in dark mode
- Use pure black (#000000) backgrounds — causes halation and looks like a void
- Simply invert your light theme colors mathematically
- Use the same shadow values for both themes — dark mode needs stronger or no shadows
- Forget to update images, illustrations, and SVG icons for dark mode
- Use thin font weights (300 or below) on dark backgrounds — they become unreadable
- Assume dark gray text on a dark background has sufficient contrast without checking
Syncing Theme Across Extension Contexts#
Extensions have multiple UI surfaces: popup, options page, side panel, content scripts. Each runs in a separate document, so applying a theme in the popup does not affect the options page. You need a synchronization mechanism.
// shared/theme-sync.ts — import in every extension page
function applyThemeToDocument(theme: "light" | "dark"): void {
document.documentElement.setAttribute("data-theme", theme);
}
// Listen for storage changes from other contexts
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && changes.theme) {
const newTheme = changes.theme.newValue as Theme;
const resolved =
newTheme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: newTheme;
applyThemeToDocument(resolved);
}
});
// Also sync for content scripts using message passing
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "THEME_CHANGED") {
applyThemeToDocument(message.resolved);
}
});For content scripts, the approach changes. Content scripts inject into web pages, so your theme must not leak into the host page. Scope everything under a unique class or shadow DOM:
// Content script: use Shadow DOM for isolated theming
const host = document.createElement("div");
const shadow = host.attachShadow({ mode: "closed" });
// Inject your tokens.css into the shadow root
const style = document.createElement("link");
style.rel = "stylesheet";
style.href = chrome.runtime.getURL("tokens.css");
shadow.appendChild(style);
// Apply theme only inside shadow DOM
const container = document.createElement("div");
container.setAttribute("data-theme", resolvedTheme);
shadow.appendChild(container);
document.body.appendChild(host);Dark Mode for Images and Icons#
Text and backgrounds are the easy part. Images, icons, and illustrations need separate treatment.
SVG icons should use currentColor so they automatically adapt:
.icon {
color: var(--text-primary);
}
.icon svg {
fill: currentColor;
stroke: currentColor;
}For raster images, you have three options:
/* Option 1: Slight dimming for photos */
[data-theme="dark"] img:not(.no-dim) {
filter: brightness(0.85);
}
/* Option 2: Provide alternate assets */
[data-theme="dark"] .logo {
content: url("./logo-dark.png");
}
/* Option 3: Themed background for transparent PNGs */
[data-theme="dark"] .screenshot {
background: var(--surface-secondary);
border-radius: 8px;
padding: 4px;
}Smooth Transitions#
Add a global transition so theme switching feels polished:
/* Apply to all elements that use theme tokens */
body,
body * {
transition: var(--theme-transition);
}
/* Disable transitions on initial load to prevent animation flash */
.no-transitions,
.no-transitions * {
transition: none !important;
}In your controller, disable transitions during initial load:
// In ThemeController.apply():
document.documentElement.classList.add("no-transitions");
document.documentElement.setAttribute("data-theme", resolved);
// Re-enable after a frame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transitions");
});
});The double requestAnimationFrame ensures the browser has committed the theme change before transitions re-enable. Without this, you get a flash-transition on the first paint.
Accessibility Verification#
Contrast ratios are not suggestions. WCAG 2.1 AA requires 4.5:1 for normal text and 3:1 for large text (18px+ or 14px+ bold). Dark mode makes it easy to accidentally drop below these thresholds.
Checklist
- All body text has at least 4.5:1 contrast ratio against its background in both themes
- Headings and large text meet 3:1 minimum contrast
- Interactive elements (buttons, links) have distinct focus indicators visible in dark mode
- Form inputs have visible borders or backgrounds — not just bottom borders that disappear
- Disabled states are distinguishable from enabled states without relying on color alone
- Error messages and validation states use icons or text in addition to color
- Charts and data visualizations remain distinguishable with sufficient color differentiation
- The theme toggle itself is accessible: labeled, keyboard-operable, and screen-reader friendly
Use Chrome DevTools to check contrast: inspect any text element, and the color picker shows the contrast ratio against its computed background. For automated checking, the axe DevTools extension catches contrast issues across your entire popup in one scan.
Testing Your Implementation#
Do not ship dark mode after testing it once. Theme bugs are context-dependent.
Test these scenarios: switching theme while the popup is open, switching the OS theme while the extension is running, opening the popup for the first time after installing with dark OS theme, opening multiple extension pages (popup + options) and switching theme from one of them, and content script appearance on both light and dark web pages.
For the popup specifically, test at different widths. Chrome extension popups can be 25px to 800px wide and 25px to 600px tall. Dark mode color choices that look fine in a wide layout might create contrast issues when elements compress.
Building dark mode well takes effort upfront, but it pays dividends. Users spend more time in extensions that respect their visual preferences, and a well-implemented dark mode signals that you care about the details. For more on creating polished extension interfaces, see our guides on designing extension icons and extension branding.
Continue reading
Related articles
Designing Responsive Extension Popups
How to design responsive Chrome extension popups that look great at every size. Covers layout strategies, CSS techniques, component patterns, and testing across screen sizes.
Extension Accessibility Checklist
Complete WCAG 2.1 AA accessibility checklist for Chrome extensions — keyboard navigation, screen readers, contrast, ARIA, focus management, and testing with assistive technologies.
Extension Options Page UX Best Practices
Design better Chrome extension options pages with proven UX patterns. Covers layout strategies, form design, save behavior, accessibility, and responsive settings interfaces.