Skip to main content

CSS Dark Mode System: Complete Implementation Guide

Dark mode is no longer optional in premium web design — it's expected. This guide covers every approach from simple prefers-color-scheme to full manual toggle with persistence, and shows how to implement a complete dark mode system in WordPress.

1. The Three Dark Mode Approaches

Approach 1: System-only (CSS-only, no JS)
→ Follows OS preference automatically
→ No toggle control for user
→ Simplest to implement

Approach 2: Manual toggle (JS + CSS)
→ User overrides system preference
→ No persistence — resets on page reload
→ Medium complexity

Approach 3: Manual toggle + localStorage (JS + CSS + Storage)
→ User overrides + remembers preference
→ Production-grade UX
→ Full complexity — recommended for client sites

2. Foundation: The Token-First Approach

The key to a maintainable dark mode system is semantic color tokens. You never write background: #0f0f0f — you write background: var(--color-bg), then change what that token means per theme.

/* ── :root = light mode defaults ── */
:root {
/* Backgrounds */
--color-bg: #ffffff;
--color-surface: #f8f8f8;
--color-surface-2: #f0f0f0;
--color-surface-3: #e8e8e8;

/* Text */
--color-text: #1a1a1a;
--color-text-muted: #666666;
--color-text-subtle: #999999;

/* Brand (same in both modes — usually) */
--color-primary: hsl(245, 80%, 58%);
--color-primary-dark: hsl(245, 80%, 45%);
--color-primary-light: hsl(245, 80%, 75%);

/* Semantic */
--color-danger: hsl(0, 80%, 55%);
--color-success: hsl(145, 65%, 38%);
--color-warning: hsl(38, 90%, 50%);

/* Borders */
--color-border: rgba(0, 0, 0, 0.10);
--color-border-strong: rgba(0, 0, 0, 0.20);

/* Shadows */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.12);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.16);

/* Invert filter for images in dark mode (placeholder) */
--filter-invert: none;
}

/* ── Dark mode token overrides ── */
[data-theme="dark"],
.dark {
--color-bg: #0f0f0f;
--color-surface: #1a1a1a;
--color-surface-2: #242424;
--color-surface-3: #2d2d2d;

--color-text: #f0f0f0;
--color-text-muted: #888888;
--color-text-subtle: #555555;

--color-primary: hsl(245, 80%, 68%); /* Slightly lighter in dark mode */
--color-primary-dark: hsl(245, 80%, 55%);

--color-border: rgba(255, 255, 255, 0.08);
--color-border-strong: rgba(255, 255, 255, 0.16);

--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.7);

--filter-invert: invert(1) hue-rotate(180deg); /* For dark-mode-aware images */
}

3. Approach 1: System Preference Only (CSS-Only)

/* Respects OS dark mode — zero JavaScript */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f0f;
--color-surface: #1a1a1a;
--color-text: #f0f0f0;
--color-text-muted: #888888;
--color-border: rgba(255, 255, 255, 0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5);
}
}

/* All your component CSS uses tokens — automatically adapts */
body { background: var(--color-bg); color: var(--color-text); }
.c-card { background: var(--color-surface); border-color: var(--color-border); }
.c-nav { background: var(--color-bg); border-bottom: 1px solid var(--color-border); }
.c-btn--primary { background: var(--color-primary); }

Limitation: User cannot override their OS preference — no toggle.


4. Approach 2: System + Manual Toggle

HTML Structure

<!-- On <html> tag — JS adds/removes data-theme="dark" -->
<html lang="en" data-theme="light">
<head>...</head>
<body>

<!-- Dark mode toggle button -->
<button class="c-theme-toggle js-theme-toggle" aria-label="Toggle dark mode">
<span class="c-theme-toggle__icon c-theme-toggle__icon--sun">☀️</span>
<span class="c-theme-toggle__icon c-theme-toggle__icon--moon">🌙</span>
</button>

</body>
</html>

CSS for the Toggle Button

/* Theme toggle button */
.c-theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-surface);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
font-size: 1rem;
}
.c-theme-toggle:hover { background: var(--color-surface-2); }

/* Show/hide icons based on current theme */
.c-theme-toggle__icon--moon { display: none; }
.c-theme-toggle__icon--sun { display: block; }

[data-theme="dark"] .c-theme-toggle__icon--moon { display: block; }
[data-theme="dark"] .c-theme-toggle__icon--sun { display: none; }

/* Smooth global transition when theme changes */
*, *::before, *::after {
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.2s ease;
}

/* Disable transitions during initial page load */
.no-transition, .no-transition * {
transition: none !important;
}

5. Approach 3: Full System — Toggle + LocalStorage (Production)

// theme.js — load this in <head> BEFORE any CSS rendering
// (prevents flash of wrong theme)

(function () {
const STORAGE_KEY = 'theme-preference';

function getThemePreference() {
// 1. Check localStorage for saved preference
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;

// 2. Fall back to OS preference
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}

function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}

// Apply theme immediately (before page render — prevents flash)
document.documentElement.classList.add('no-transition');
applyTheme(getThemePreference());

// Remove no-transition class after first paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
});

// Listen for OS preference changes (user changes system setting)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem(STORAGE_KEY)) {
applyTheme(e.matches ? 'dark' : 'light');
}
});

// Set up toggle button after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const toggleBtn = document.querySelector('.js-theme-toggle');
if (!toggleBtn) return;

toggleBtn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
});
})();

CSS-Only Toggle (No JavaScript — checkbox hack)

<!-- Pure CSS toggle — no JS needed -->
<input type="checkbox" id="theme-toggle" class="c-theme-checkbox" hidden>
<label for="theme-toggle" class="c-theme-label" aria-label="Toggle dark mode">
<span class="c-theme-label__track">
<span class="c-theme-label__thumb"></span>
</span>
<span class="c-theme-label__icon">🌙</span>
</label>

<!-- All your page content AFTER the checkbox -->
<div class="l-page">...</div>
/* CSS-only dark mode via :checked state */
.c-theme-checkbox:checked ~ .l-page {
--color-bg: #0f0f0f;
--color-surface: #1a1a1a;
--color-text: #f0f0f0;
--color-border: rgba(255,255,255,0.08);
}

/* Toggle switch styling */
.c-theme-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.c-theme-label__track {
width: 3rem; height: 1.5rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
position: relative;
transition: background 0.3s;
}
.c-theme-label__thumb {
position: absolute;
top: 2px; left: 2px;
width: 1.125rem; height: 1.125rem;
background: var(--color-primary);
border-radius: 50%;
transition: transform 0.3s var(--ease-bounce);
}
.c-theme-checkbox:checked ~ * .c-theme-label__thumb,
.c-theme-checkbox:checked + .c-theme-label .c-theme-label__thumb {
transform: translateX(1.5rem);
}

6. Dark Mode Image Handling

Images designed for light mode look terrible on dark backgrounds. Handle them properly:

/* Option 1: Slightly dim all images in dark mode — most universal */
[data-theme="dark"] img:not([src*=".svg"]) {
filter: brightness(0.85) saturate(0.95);
}

/* Option 2: Provide separate dark mode images via art direction */
/* HTML: <picture> element with media */

/* Option 3: SVG logos — invert in dark mode */
[data-theme="dark"] .c-nav__logo img[src*=".svg"] {
filter: brightness(0) invert(1); /* Black logo → white */
}

/* Option 4: Use CSS variables for themed SVG */
.c-icon {
color: var(--color-text); /* SVGs inherit color for fill/stroke */
}

/* Option 5: Different image per theme via CSS */
.hero-image {
content: url('/img/hero-light.jpg');
}
[data-theme="dark"] .hero-image {
content: url('/img/hero-dark.jpg');
}

7. Dark Mode Shadows

Light mode shadows use rgba(0,0,0,x). Dark mode needs different treatment:

/* Light mode: dark shadows on white */
:root {
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.16);
--shadow-primary: 0 4px 20px rgba(108, 99, 255, 0.25);
}

/* Dark mode: deeper shadows + optional glow effect */
[data-theme="dark"] {
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.8);
--shadow-primary: 0 4px 20px rgba(108, 99, 255, 0.4); /* Glow stronger */
}

/* Glass effect: works in both modes automatically */
.c-card--glass {
background: rgba(255, 255, 255, 0.05); /* Low opacity white — works both */
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
}
/* In dark mode: glass looks great naturally */
/* In light mode: glass looks like frosted effect */

8. WordPress Dark Mode Implementation

Option A: Customizer Color Scheme

// functions.php — add dark/light scheme via body class
function add_theme_body_class($classes) {
$theme = isset($_COOKIE['theme-preference']) ? $_COOKIE['theme-preference'] : 'light';
$classes[] = 'theme-' . sanitize_text_field($theme);
return $classes;
}
add_filter('body_class', 'add_theme_body_class');
/* In child theme: target body.theme-dark */
body.theme-dark {
--color-bg: #0f0f0f;
--color-surface: #1a1a1a;
--color-text: #f0f0f0;
--color-border: rgba(255,255,255,0.08);
}

Option B: GeneratePress Dark Mode

/* Override GeneratePress with CSS variables for dark mode */
[data-theme="dark"] {
/* GeneratePress-specific resets */
--contrast: 240, 240, 240; /* GP --contrast */
--contrast-2: 180, 180, 180; /* GP --contrast-2 */
--base: 30, 30, 30; /* GP --base */
--base-2: 20, 20, 20; /* GP --base-2 */
--contrast-3: 120, 120, 120;

--color-bg: #111111;
--color-surface: #1c1c1c;
--color-text: #e8e8e8;
}

[data-theme="dark"] body { background: var(--color-bg); color: var(--color-text); }
[data-theme="dark"] .site-header { background: rgba(17,17,17,0.9); }
[data-theme="dark"] .main-navigation a { color: var(--color-text-muted); }
[data-theme="dark"] .widget { background: var(--color-surface); border-color: var(--color-border); }

9. Dark Mode Color Design with OKLCH

OKLCH is the best color space for dark mode because it maintains perceptual consistency:

/* OKLCH dark mode palette — perceptually balanced */
:root {
--primary-l: 0.6; /* Lightness */
--primary-c: 0.2; /* Chroma (saturation) */
--primary-h: 280; /* Hue (purple) */

--color-primary: oklch(var(--primary-l) var(--primary-c) var(--primary-h));
}

/* In dark mode: increase lightness for readability, increase chroma for vibrancy */
[data-theme="dark"] {
--primary-l: 0.72; /* Lighter for dark background */
--primary-c: 0.22; /* Slightly more saturated */
/* Hue stays the same — perceptual consistency */

--color-primary: oklch(var(--primary-l) var(--primary-c) var(--primary-h));
}

/* Additional semantic colors */
:root {
--color-success: oklch(0.55 0.18 145);
--color-danger: oklch(0.58 0.22 25);
--color-warning: oklch(0.65 0.18 80);
}
[data-theme="dark"] {
--color-success: oklch(0.70 0.18 145);
--color-danger: oklch(0.72 0.22 25);
--color-warning: oklch(0.78 0.18 80);
}

10. Dark Mode Testing Checklist

□ Light mode: All text passes WCAG AA contrast (4.5:1 minimum)
□ Dark mode: All text passes WCAG AA contrast
□ Check focus rings visible in both modes
□ Check placeholder text color in forms (both modes)
□ Check disabled button states (both modes)
□ Check code blocks / pre elements
□ Check images — not too bright or dark
□ Check SVG logos (may need filter inversion)
□ Check box shadows — visible on dark backgrounds?
□ Check hover states — visible in both modes
□ Check borders — visible in dark mode (rgba often disappears)
□ Test localStorage persistence across page refreshes
□ Test OS preference change while page is open
□ Check no flash of wrong theme on page load
□ Test in WordPress admin + frontend (admin bar = 32px offset)
□ Run browser DevTools → Rendering → Emulate dark mode