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:
- System →
light dark - Light →
light - Dark →
dark
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-image — light-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.