Back to Blog
How to Build a Dark Mode Color System
April 16, 2026

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.