ibit
Notes

Themes without the variable duplication: CSS light-dark()

WRWhite Rabbit·June 29, 2026·4 min

Here's the shape of dark mode I've been writing for years:

:root {
  --bg: white;
  --text: #111;
  --border: #ddd;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #111;
    --text: #eee;
    --border: #333;
  }
}

Every token shows up twice. The two values that belong together — light and dark for the same idea — live in different blocks, and it's on me to keep them in sync. Add a token, and there are two edits. Forget the second one, and you get a white border that survives into dark mode.

light-dark() rewrites that. It takes two colors and returns the first under a light color scheme, the second under a dark one:

:root {
  color-scheme: light dark;
  --bg: light-dark(white, #111);
  --text: light-dark(#111, #eee);
  --border: light-dark(#ddd, #333);
}

One block. The pair lives on one line, where it's obvious. No media query in sight.

color-scheme is the actual switch

The detail that made it click for me: light-dark() doesn't read prefers-color-scheme. It reads the computed color-scheme property. That indirection is the whole point.

color-scheme: light dark means "I support both; follow the system." But color-scheme is a normal inherited property, so you can pin it anywhere:

.invoice-preview {
  color-scheme: light; /* always light, even on a dark OS */
}

Every light-dark() inside that subtree now resolves to its light value, regardless of the user's OS. The old media query couldn't do that — it was global and read-only.

As a bonus, color-scheme also tells the browser to theme the native bits — form controls, scrollbars, the default canvas — to match. The media-query approach never touched those.

A manual light / dark / system switch

This is where the indirection pays off. Because everything keys off one property, a three-state toggle is just three values written to color-scheme:

Pure CSS, if you don't need persistence, via :has() on a radio group:

:root { color-scheme: light dark; }
:root:has(#theme-light:checked) { color-scheme: light; }
:root:has(#theme-dark:checked)  { color-scheme: dark; }

Or with a sliver of JS to remember the choice across visits:

const root = document.documentElement;
const saved = localStorage.getItem("theme"); // "light" | "dark" | "light dark"
if (saved) root.style.colorScheme = saved;

Either way, the OS preference and the manual override write to the same knob. There's no "which mode is active?" detection, no media-query listener — the cascade resolves it.

Where it doesn't reach

It's a color function. A box-shadow, an SVG fill passed as a string, a background-imagelight-dark() won't branch those. And here's the trap: if you reach back for @media (prefers-color-scheme: dark) to handle them, that media query only ever sees the OS setting. It can't see your manual override, so a user who forced dark mode gets a light shadow.

The fix is to branch those off color-scheme too — a :root:has(...) selector or a data-theme attribute — so non-color properties stay on the same source of truth as everything else.

Support

light-dark() has been Baseline Newly Available since May 2024 (Chrome 123, Firefox 120, Safari 17.5), and is on track to reach Widely Available in November 2026. Safe enough that I'd ship it today, but I'd still leave a plain fallback for the long tail — the cascade makes that one extra line:

:root {
  --bg: white; /* older browsers stop here */
  --bg: light-dark(white, #111);
}

The mental shift is small but real: dark mode stops being a second stylesheet layered on with a media query, and becomes a property of each color, switched by one inherited value. Fewer places for the two halves of a theme to drift apart.