One Color System, Two Themes: Engineering Light and Dark Mode Together

The problem with "two palettes"

Many teams build their light theme first, ship it, and then bolt on dark mode later as a completely separate set of colors. The result is predictable: the two themes drift apart. A new component looks great in light mode and unreadable in dark, because nobody remembered to define the dark variant.

The fix is architectural. You should never have a "light palette" and a "dark palette." You should have one set of semantic tokens with two value sets behind them.

Semantic tokens are theme-agnostic

When your components reference intent (--color-surface, --color-text-primary) instead of appearance (--white, --black), switching themes is just swapping the values:

:root {
  --color-bg:           #F8FAFC;
  --color-surface:      #FFFFFF;
  --color-text-primary: #0F172A;
  --color-border:       #E2E8F0;
}

[data-theme="dark"] {
  --color-bg:           #020617;
  --color-surface:      #0F172A;
  --color-text-primary: #E5E7EB;
  --color-border:       rgba(255,255,255,0.08);
}

The components never change. Only the values under the same names do. This is why a well-tokenized app can switch themes instantly with zero rebuild.

Dark mode is not "inverted" light mode

A common shortcut is to mathematically invert lightness. It never looks right. Three rules matter:

  • Don't use pure black. A tinted near-black like #020617 or #0F172A reduces eye strain and halation compared to #000000.
  • Show elevation with lightness, not shadow. Shadows are nearly invisible on dark backgrounds. Make elevated surfaces (cards, modals) lighter than the page behind them.
  • Desaturate your accents. A color that looks vivid on white "vibrates" on black. Lower saturation 10–20% and often raise lightness to keep contrast.

We go deeper on the perceptual side in Designing Dark Mode: It's Not Just Inverting Colors.

Keep both themes accessible

Contrast requirements (4.5:1 for body text, 3:1 for large text) apply to both themes. The trap is muted text: a secondary gray that passes in light mode often fails on a dark surface. Verify each semantic text token against each background token in both themes. Our palette detail pages show a real-time contrast score for exactly this reason—test the combination, not the swatch in isolation.

A repeatable workflow

  1. Define semantic tokens once (bg, surface, border, text-primary, text-muted, primary, danger, success).
  2. Fill in the light value set, then the dark value set, for every token.
  3. Check contrast for each text-on-surface pairing in both themes.
  4. Build components against semantic names only—never hardcode a hex.
  5. Add a new component? You're forced to define both theme values up front, so drift can't happen.

If you want a head start, our dark mode UI color palettes are tuned for comfortable contrast and pair naturally with light counterparts. Combine that with a semantic token system and you'll maintain one coherent system instead of two diverging ones.