cssdark modecss variablestheming

Dark mode with CSS variables: full theming without duplicating styles

How to build professional dark mode: custom properties as tokens, prefers-color-scheme, manual toggle with data-attribute and transition without flash.

August 27, 2026·8 min read

Well-made dark mode isn't "set background to black": it's redesigning the surface hierarchy so visual hierarchy survives the change. The tool making it maintainable is CSS variables used as a token system — one set of values, two themes, zero duplication.

The base pattern: semantic tokens

The beginner mistake is conditioning every color directly:

/* FRAGILE: every component repeats the condition */
.card { background: white; }
@media (prefers-color-scheme: dark) { .card { background: #1a1a1a; } }

The correct pattern defines semantic tokens — names describing function, not value:

:root {
  --bg-surface: #ffffff;
  --bg-page: #f5f5f7;
  --text-primary: #111418;
  --text-muted: #5c6370;
  --border-subtle: rgba(0, 0, 0, 0.08);
  --accent: #7c3aed;
}

[data-theme="dark"] {
  --bg-surface: #1c1e26;
  --bg-page: #101116;
  --text-primary: #e8eaed;
  --text-muted: #9aa0ab;
  --border-subtle: rgba(255, 255, 255, 0.09);
  --accent: #9d71ff;
}

And components consume tokens, never literal colors:

.card {
  background: var(--bg-surface);
  color: var(--text-primary);
  border: 1px solid var(--border-subtle);
}

Adding a new theme (high contrast, sepia, seasonal brand) becomes defining another token block. Components don't even notice.

Follow the system AND allow choice

Users expect three options: light, dark, automatic. The standard pattern combines prefers-color-scheme with a stored manual preference:

const stored = localStorage.getItem("theme");       // "light" | "dark" | null
const system = matchMedia("(prefers-color-scheme: dark)").matches;
const theme = stored ?? (system ? "dark" : "light");
document.documentElement.dataset.theme = theme;

// Manual toggle
document.querySelector("#toggle").onclick = () => {
  const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
  document.documentElement.dataset.theme = next;
  localStorage.setItem("theme", next);   // null = follow system again
};

For the SYSTEM theme to also work when the user hasn't chosen:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { /* dark tokens */ }
}

Or simpler: resolve everything in JS on load (snippet above) and keep only [data-theme="dark"] in CSS — less CSS, same UX, at the cost of requiring JS for auto mode.

The white flash: theme FOUC

Carelessly, dark-mode users see a white flash before your JS loads. Classic fix: minimal inline script in <head>, BEFORE any render:

<script>
  (function(){
    var t = localStorage.getItem("theme");
    if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
    document.documentElement.dataset.theme = t;
  })();
</script>

Five synchronous lines eliminating the flash entirely. Next.js wraps this in next-themes, which also manages hydration without mismatch.

Designing dark ≠ inverting colors

Dark-theme-specific rules separating professional results from auto-inverted ones:

  • Never pure black (#000): creates halos on OLED and brutal contrasts. Desaturated dark surfaces (#101116 → #1c1e26) create depth.
  • Invert the elevation hierarchy: in light mode, "higher" = whiter; in dark, "higher" = lighter than background.
  • Reduce intensity, not saturation: vivid colors vibrate over dark backgrounds. Lower luminance or raise accent lightness (#7c3aed → #9d71ff).
  • Nearly invisible shadows: barely visible over dark backgrounds; partially replace with subtle borders (border-subtle).
  • Images and logos: audit black-on-transparent assets; many sites serve per-theme SVG variants.

If designing palettes from scratch, build light tokens first and derive darks by adjusting lightness — our palette generator gives harmonious bases for both worlds, and the color spaces guide explains why OKLCH eases those perceptual derivations.

Smooth theme transition

A global color transition avoids the dry switch:

html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
  transition: background-color .3s ease, border-color .3s ease, color .2s ease !important;
}

Apply the class only during toggle (add-class → switch theme → remove after 300ms): keeping it permanent penalizes scroll performance by animation rules.

FAQ

Should I respect prefers-color-scheme if my brand is light? Recommended: default to auto and let users choose. Forcing light against the system is the number-one complaint on big apps.

Do variables work everywhere? Universal support since 2017 (IE11 out). For extreme legacy, compile static fallbacks with PostCSS.

How do I test both themes fast? DevTools → Rendering → Emulate CSS media feature prefers-color-scheme. Combined with your manual toggle it covers all three states.


Build coherent palettes for both themes with our palette generator, free and right in your browser.

Try it without code

Color Palettes

Harmonic color combinations.

Open Color Palettes

Built by

Miguel Ángel Colorado Marin (MACM)

Full-Stack Developer · Guadalajara, España

I develop web apps, digital tools and full projects — from design to deployment.

Contact me