How to Build a Dark Mode Color System
Step-by-step guide to building a dark mode color system using HSL, CSS variables, and semantic tokens. Includes contrast checks and real examples.
How to Build a Dark Mode Color System
Dark mode is no longer optional. Over 50% of mobile users prefer it, and OS-level dark mode is standard across iOS and Android. Building it right requires a systematic approach.
🎨 Try it free: Tints & Shades Generator — Generate light and dark color scales from any base color.
The Core Problem with Dark Mode
Most developers "add dark mode" by inverting colors. This fails because:
- Pure black (#000000) backgrounds feel harsh and cause eye strain
- Simply inverting light colors produces ugly, clashing results
- Brand colors that work on white often fail on dark backgrounds
The solution: design two color systems from the start, sharing the same hue but with different lightness values.
Step 1: Use Semantic Token Names
Never bind your tokens to visual descriptions:
/* ❌ Wrong — breaks in dark mode */
--white-background: #FFFFFF;
--dark-text: #1A1A2E;
/* ✅ Right — semantic, mode-agnostic */
--color-surface: #FFFFFF;
--color-on-surface: #1A1A2E;
Step 2: Define Both Modes with HSL
/* Light mode */
:root {
--color-surface: hsl(0, 0%, 100%);
--color-on-surface: hsl(225, 25%, 12%);
--color-primary: hsl(225, 95%, 58%);
--color-surface-low: hsl(210, 20%, 96%);
}
/* Dark mode */
[data-theme="dark"] {
--color-surface: hsl(225, 25%, 10%);
--color-on-surface: hsl(0, 0%, 95%);
--color-primary: hsl(225, 95%, 68%); /* lighter for dark bg */
--color-surface-low: hsl(225, 20%, 14%);
}
Key insight: dark mode primary colors need higher lightness than light mode equivalents to maintain the same perceived vibrancy.
Step 3: Check Contrast in Both Modes
Your colors must pass WCAG in both light and dark mode. A color that passes 4.5:1 on white may fail on dark grey.
Test every combination with Contrast Checker:
- Text on surface ✅
- Primary on surface ✅
- Primary on dark surface ✅
Step 4: Adjust Brand Colors for Dark Backgrounds
Most brand blues, purples, and reds need to be lightened for dark mode:
/* Light mode: darker primary reads well on white */
--color-primary: hsl(225, 95%, 50%);
/* Dark mode: lighter primary reads well on near-black */
--color-primary: hsl(225, 95%, 68%);
Use Tints & Shades Generator to find the right dark-mode value that passes contrast.
Step 5: Implement Mode Switching
// React example
const toggleTheme = () => {
document.documentElement.setAttribute(
'data-theme',
document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'
);
};
Or respect OS preference automatically:
@media (prefers-color-scheme: dark) {
:root {
--color-surface: hsl(225, 25%, 10%);
--color-on-surface: hsl(0, 0%, 95%);
}
}
Dark Mode Mistakes to Avoid
Pure black backgrounds — Use dark navy or very dark grey (5–10% lightness) instead of #000000.
Bright saturated colors on dark — Reduce saturation slightly (90% → 75%) for dark mode — pure colors can feel harsh.
Forgetting focus states — Focus rings that work on white may be invisible on dark backgrounds.
FAQs
Should I use prefers-color-scheme or a toggle?
Both. Respect the OS preference by default, but give users a manual toggle.
Do I need completely different color palettes for dark mode? No — same hues, different lightness values. HSL makes this easy.
How do I handle images in dark mode?
Add filter: brightness(0.85) to images in dark mode — this prevents pure-white images from feeling jarring.
Conclusion
Dark mode is a color system problem, not a CSS inversion problem. Build semantic tokens → generate both scales with Tints & Shades Generator → verify both modes with Contrast Checker.