How do you implement dark mode?
/* Like this? */
.dark .card {
background: #1a1a1a;
color: #ffffff;
}
.light .card {
background: #ffffff;
color: #1a1a1a;
}
Adding .dark branches to every component doubles your CSS.
CSS variables make it much cleaner.
Basic Structure
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-border: #e5e5e5;
}
.dark {
--color-bg: #1a1a1a;
--color-text: #ffffff;
--color-border: #333333;
}
.card {
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
}
One .card style. Mode switch just changes variable values.
Semantic Tokens
Name colors by purpose for better clarity:
:root {
/* Light mode */
--color-surface: #ffffff; /* cards, modals */
--color-background: #f5f5f5; /* page background */
--color-text-primary: #1a1a1a; /* main text */
--color-text-secondary: #666666; /* secondary text */
--color-border: #e5e5e5;
}
.dark {
--color-surface: #262626;
--color-background: #1a1a1a;
--color-text-primary: #ffffff;
--color-text-secondary: #a3a3a3;
--color-border: #404040;
}
--color-surface is clearer than --color-gray-100. No need to look up what it means.
Using with Tailwind
// tailwind.config.ts
export default {
theme: {
extend: {
colors: {
surface: 'var(--color-surface)',
background: 'var(--color-background)',
'text-primary': 'var(--color-text-primary)',
'text-secondary': 'var(--color-text-secondary)',
border: 'var(--color-border)',
},
},
},
};
<div className="bg-surface text-text-primary border-border">
<p className="text-text-secondary">Secondary text</p>
</div>
No dark: prefix needed. It switches automatically.
Mode Toggle Logic
// theme-toggle.tsx
function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Detect system preference
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
// Check saved preference
const saved = localStorage.getItem('theme');
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
setIsDark(true);
}
}, []);
function toggle() {
const newValue = !isDark;
setIsDark(newValue);
if (newValue) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
return (
<button onClick={toggle}>{isDark ? 'Light Mode' : 'Dark Mode'}</button>
);
}
Key is adding/removing dark from document.documentElement.classList.
Preventing Flash
SSR or Next.js has a flash problem on page load. Solution:
<!-- layout.tsx or _document.tsx -->
<head>
<script dangerouslySetInnerHTML={{
__html: `
(function() {
var theme = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
`
}} />
</head>
This script runs before render, no flash.
Color Palette Tips
Don't just invert for dark mode:
❌ Light: #ffffff → Dark: #000000
Too much contrast, hurts eyes
✅ Light: #ffffff → Dark: #1a1a1a
Slightly lighter black is comfortable
Same for text:
❌ Light: #000000 → Dark: #ffffff
Pure white causes eye strain
✅ Light: #1a1a1a → Dark: #f5f5f5
Slightly darker white is better
Proven Color Set
Battle-tested combinations:
:root {
/* Surface */
--color-surface: #ffffff;
--color-surface-elevated: #ffffff;
/* Background */
--color-background: #f5f5f5;
/* Text */
--color-text-primary: #171717;
--color-text-secondary: #525252;
--color-text-tertiary: #a3a3a3;
/* Border */
--color-border: #e5e5e5;
--color-border-strong: #d4d4d4;
/* Primary */
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
}
.dark {
--color-surface: #262626;
--color-surface-elevated: #333333;
--color-background: #171717;
--color-text-primary: #fafafa;
--color-text-secondary: #a3a3a3;
--color-text-tertiary: #737373;
--color-border: #404040;
--color-border-strong: #525252;
--color-primary: #60a5fa;
--color-primary-hover: #3b82f6;
}
Primary colors should be brighter in dark mode for visibility.
Watch Out For
1. Images
/* Invert logo in dark mode */
.dark .logo {
filter: invert(1);
}
/* Or prepare two versions */
2. Shadows
:root {
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.dark {
/* Darker shadows in dark mode */
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
3. No Hardcoded Colors
/* ❌ Dark mode won't work */
<div className="bg-white text-black">
/* ✅ Use variables */
<div className="bg-surface text-text-primary">
Summary
- Define colors as CSS variables
- Change only variable values in
.dark - Components reference only variables
- Use semantic names (
surface,text-primary, etc.) - Add flash prevention script for SSR
This makes dark mode maintenance much easier.
CSS, darkmode, CSSvariables, TailwindCSS, React, frontend
Top comments (1)
Shifting from primitive hex codes to semantic tokens is exactly what stops multi-theme architectures from collapsing under their own weight. I picked up a book on Amazon called Ship Your Design System recently, and the token tiers chapter breaks down this exact mapping approach to avoid duplicating CSS classes. How are you currently managing the handoff when the design team updates these semantic names in Figma?