Designing Dark Mode for a Firefox Extension: Beyond Just Inverting Colors
Every new tab extension should support dark mode. But good dark mode isn't just filter: invert() or flipping colors — it requires intentional design decisions. Here's how I implemented dark mode in the Weather & Clock Dashboard extension.
The Core Principle: Surfaces, Not Just Colors
Good dark mode thinks in surfaces:
- Background: 0% lightness (true black) vs 12-15% (near-black)
- Surface 1 (cards, panels): slightly lighter than background
- Surface 2 (hover states): slightly lighter than Surface 1
- Text primary: ~87% opacity white
- Text secondary: ~60% opacity white
Material Design's dark theme guide is worth reading: this elevation-based approach prevents dark mode from looking flat.
CSS Custom Properties for Theming
:root {
/* Light mode (default) */
--bg-primary: #f0f2f5;
--bg-surface: rgba(255, 255, 255, 0.85);
--bg-surface-hover: rgba(255, 255, 255, 0.95);
--text-primary: #1a1a2e;
--text-secondary: #4a5568;
--text-muted: #718096;
--border: rgba(0, 0, 0, 0.08);
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-primary: #0f0f14;
--bg-surface: rgba(255, 255, 255, 0.06);
--bg-surface-hover: rgba(255, 255, 255, 0.1);
--text-primary: rgba(255, 255, 255, 0.87);
--text-secondary: rgba(255, 255, 255, 0.6);
--text-muted: rgba(255, 255, 255, 0.38);
--border: rgba(255, 255, 255, 0.08);
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
body {
background: var(--bg-primary);
color: var(--text-primary);
}
Applying the Theme
const STORAGE_KEY = 'theme';
async function initTheme() {
const { theme } = await browser.storage.local.get(STORAGE_KEY);
applyTheme(theme || getSystemTheme());
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
// Update toggle button state
const toggle = document.getElementById('theme-toggle');
if (toggle) toggle.setAttribute('aria-pressed', theme === 'dark');
}
async function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
await browser.storage.local.set({ [STORAGE_KEY]: next });
}
// Initialize on load
initTheme();
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
browser.storage.local.get(STORAGE_KEY).then(({ theme }) => {
if (!theme) applyTheme(e.matches ? 'dark' : 'light');
});
});
The Toggle Button
<button
id="theme-toggle"
class="icon-btn theme-toggle"
aria-label="Toggle dark mode"
aria-pressed="false"
title="Toggle dark/light mode (D)"
>
<svg class="icon-sun" viewBox="0 0 24 24"><!-- sun icon --></svg>
<svg class="icon-moon" viewBox="0 0 24 24"><!-- moon icon --></svg>
</button>
.theme-toggle .icon-sun {
display: block;
}
.theme-toggle .icon-moon {
display: none;
}
[data-theme="dark"] .theme-toggle .icon-sun {
display: none;
}
[data-theme="dark"] .theme-toggle .icon-moon {
display: block;
}
Transition Animation
A smooth transition makes the theme switch feel polished:
body {
transition:
background-color 0.3s ease,
color 0.2s ease;
}
/* Don't transition on page load — only on user action */
body.no-transition {
transition: none !important;
}
async function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
// Enable transition
document.body.classList.remove('no-transition');
applyTheme(next);
await browser.storage.local.set({ [STORAGE_KEY]: next });
}
// Prevent flash of wrong theme
async function initTheme() {
// Disable transitions during init
document.body.classList.add('no-transition');
const { theme } = await browser.storage.local.get(STORAGE_KEY);
applyTheme(theme || getSystemTheme());
// Re-enable transitions after a frame
requestAnimationFrame(() => {
document.body.classList.remove('no-transition');
});
}
Common Dark Mode Pitfalls
1. Images with white backgrounds
/* Invert images in dark mode if they have white backgrounds */
[data-theme="dark"] img.light-bg {
filter: invert(0.85) hue-rotate(180deg);
}
2. Transparent inputs
input, textarea {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
/* Important: explicitly set color, not just background */
input::placeholder {
color: var(--text-muted);
}
3. Third-party embeds (weather icons)
/* Soften bright weather icons in dark mode */
[data-theme="dark"] .weather-icon {
opacity: 0.9;
filter: brightness(0.9);
}
Respecting the System Default
A good extension respects prefers-color-scheme when no preference has been saved:
/* Pure CSS approach — no JS needed for the initial state */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg-primary: #0f0f14;
/* ... rest of dark theme variables */
}
}
This prevents the flash of light mode before JS loads.
The Weather & Clock Dashboard uses this exact approach — try toggling dark mode after installing.
Top comments (0)