#css /
Building a Dark Mode Theme with CSS Variables
Implement dark mode switching with CSS variables, from design specifications to code implementation, creating a maintainable theme system.
Goal
Implement a scalable, maintainable, performance-first dark mode solution. Not just simply inverting colors, but building a complete theme system.
Background
The Rise of Dark Mode
In 2019, Apple introduced system-level dark mode in iOS 13, followed by Android 10 and Windows 10. Dark mode went from "nice to have" to "must have."
But many frontend developers implement it this way:
/* Brute force approach: two complete sets of styles */
body.light { /* 200 lines of color definitions */ }
body.dark { /* Another 200 lines of color definitions */ }
The problems with this approach:
- Severe code duplication
- Need to add styles in two places when adding new components
- Maintenance cost increases linearly with project growth
CSS variables are the best solution to this problem.
CSS Variable Basics
Definition and Usage
:root {
/* Theme colors */
--color-primary: #1890ff;
--color-success: #52c41a;
--color-warning: #faad14;
--color-error: #ff4d4f;
/* Text colors */
--text-primary: rgba(0, 0, 0, 0.85);
--text-secondary: rgba(0, 0, 0, 0.65);
--text-disabled: rgba(0, 0, 0, 0.25);
/* Background colors */
--bg-base: #ffffff;
--bg-light: #fafafa;
--bg-dark: #f0f2f5;
/* Borders */
--border-color: #d9d9d9;
--border-radius: 4px;
}
/* Using variables */
.button {
background: var(--color-primary);
color: white;
border-radius: var(--border-radius);
}
.text-secondary {
color: var(--text-secondary);
}
Dark Mode Override
/* Toggle via class */
body.dark {
--text-primary: rgba(255, 255, 255, 0.85);
--text-secondary: rgba(255, 255, 255, 0.65);
--text-disabled: rgba(255, 255, 255, 0.25);
--bg-base: #141414;
--bg-light: #1f1f1f;
--bg-dark: #262626;
--border-color: #434343;
}
/* Or follow system via media query */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--text-primary: rgba(255, 255, 255, 0.85);
--text-secondary: rgba(255, 255, 255, 0.65);
/* ... other variables */
}
}
Complete Theme System Design
1. Design Token Layering
:root {
/* === Base Tokens (raw values) === */
--gray-50: #fafafa;
--gray-100: #f5f5f5;
--gray-200: #e8e8e8;
--gray-300: #d9d9d9;
--gray-400: #bfbfbf;
--gray-500: #8c8c8c;
--gray-600: #595959;
--gray-700: #434343;
--gray-800: #262626;
--gray-900: #141414;
--blue-50: #e6f7ff;
--blue-100: #bae7ff;
--blue-200: #91d5ff;
--blue-300: #69c0ff;
--blue-400: #40a9ff;
--blue-500: #1890ff;
--blue-600: #096dd9;
--blue-700: #0050b3;
/* === Semantic Tokens (functional mapping) === */
--color-primary: var(--blue-500);
--color-primary-hover: var(--blue-400);
--color-primary-active: var(--blue-600);
--color-bg-base: var(--gray-50);
--color-bg-light: var(--gray-100);
--color-bg-dark: var(--gray-200);
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-text-disabled: var(--gray-400);
--color-border: var(--gray-300);
--color-border-light: var(--gray-200);
}
/* Dark mode: only override semantic tokens */
body.dark {
--color-primary: var(--blue-500); /* Primary color usually unchanged */
--color-primary-hover: var(--blue-400);
--color-primary-active: var(--blue-600);
--color-bg-base: var(--gray-900);
--color-bg-light: var(--gray-800);
--color-bg-dark: var(--gray-700);
--color-text-primary: rgba(255, 255, 255, 0.85);
--color-text-secondary: rgba(255, 255, 255, 0.65);
--color-text-disabled: rgba(255, 255, 255, 0.25);
--color-border: var(--gray-700);
--color-border-light: var(--gray-800);
}
2. Component-Level Theme Variables
:root {
/* Button */
--btn-height: 32px;
--btn-padding: 0 16px;
--btn-font-size: 14px;
--btn-border-radius: var(--radius-sm);
/* Input */
--input-height: 32px;
--input-padding: 0 12px;
--input-border: 1px solid var(--color-border);
--input-border-radius: var(--radius-sm);
/* Card */
--card-padding: 24px;
--card-bg: var(--color-bg-base);
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
--card-border-radius: var(--radius-md);
}
/* Dark mode card shadow adjustment */
body.dark .card {
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
3. Color Contrast Checking
Dark mode must ensure sufficient contrast (WCAG AA standard: at least 4.5:1):
function getContrastRatio(color1, color2) {
const getLuminance = (r, g, b) => {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};
const l1 = getLuminance(...color1);
const l2 = getLuminance(...color2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Test contrast
const ratio = getContrastRatio([255, 255, 255], [20, 20, 20]);
console.log(`Contrast ratio: ${ratio.toFixed(2)}:1`); // About 18.1:1, passes WCAG AAA
Theme Switching Implementation
Method 1: Class Toggle
class ThemeManager {
constructor() {
this.theme = localStorage.getItem('theme') || 'light';
this.apply();
}
toggle() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
this.apply();
localStorage.setItem('theme', this.theme);
}
apply() {
document.body.classList.remove('light', 'dark');
document.body.classList.add(this.theme);
}
// Follow system
followSystem() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.theme = mediaQuery.matches ? 'dark' : 'light';
this.apply();
mediaQuery.addEventListener('change', (e) => {
this.theme = e.matches ? 'dark' : 'light';
this.apply();
});
}
}
const themeManager = new ThemeManager();
Method 2: CSS Media Query Auto-Switching
/* Default light mode */
:root {
--color-bg-base: #ffffff;
--color-text-primary: #000000;
}
/* Follow system preference */
@media (prefers-color-scheme: dark) {
:root {
--color-bg-base: #141414;
--color-text-primary: #ffffff;
}
}
/* Allow manual user override */
body.light {
--color-bg-base: #ffffff;
--color-text-primary: #000000;
}
body.dark {
--color-bg-base: #141414;
--color-text-primary: #ffffff;
}
Common Pitfalls
1. Images Too Bright in Dark Mode
/* Add dark mode adaptation for images */
body.dark img:not(.no-filter) {
filter: brightness(0.8);
}
/* Or use mix-blend-mode */
body.dark .logo {
mix-blend-mode: screen;
}
2. Shadows Invisible in Dark Mode
:root {
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.1);
}
body.dark {
/* Shadows need to be stronger in dark mode */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
/* Or use borders instead of shadows */
}
3. SVG Icon Colors
/* Light mode */
.icon {
fill: var(--color-text-primary);
}
/* Dark mode - use currentColor */
.icon {
fill: currentColor;
color: var(--color-text-primary);
}
Summary
- CSS variables are the foundation of theme systems: Define once, use everywhere
- Layered design: Base Tokens → Semantic Tokens → Component Tokens
- Dark mode is not simple inversion: Need to consider contrast, shadows, image adaptation
- Follow system + manual override: Provides the best user experience
- Performance first: CSS variable switching is zero-cost, no need to repaint the entire page
Build your theme system with CSS variables, and your code will be cleaner, more maintainable, and more extensible.