light-dark()
A declaration that's free and nobody writes, plus the scoping trick that finally fixes a row of logos on a dark page.
Five years ago I wrote that dark mode is a data problem, and the argument was that the hard part isn’t picking colors, it’s that your stylesheet doesn’t know what any of its existing colors mean. That’s still true and it’s still the work.
But the mechanics on the other side of that audit have got considerably nicer, and at the weekend I rewrote the theming here and deleted about half of it.
Set color-scheme first, and it’s free
Before the new function, the property it depends on, which has been supported everywhere for years and which almost nobody sets.
:root { color-scheme: light dark; }
That one declaration tells the browser your page can handle both schemes, and the browser then adapts everything it draws itself. Form controls render dark. Scrollbars render dark. The default canvas color, the one you see for a fraction of a second before your CSS applies, is dark rather than white.
That last one is the flash of white that dark-mode users get on nearly every site that has implemented dark mode. All the tokens are correct, the theming is careful, and the first 80 milliseconds are a white rectangle in a dark room, because the browser was never told.
Set that property today on anything with a dark theme, regardless of the rest of this post. It’s the highest value per character in modern CSS.
What the function does
light-dark() takes two colors and resolves to the first one in a light scheme and the second in a dark one.
:root {
color-scheme: light dark;
--surface: light-dark(#ffffff, #141416);
--surface-raised: light-dark(#f6f6f4, #1e1e22);
--text: light-dark(#1b1b1b, #e8e6e3);
--text-muted: light-dark(#5f5f5f, #a2a09c);
--border: light-dark(#e2e2df, #2e2e33);
--accent: light-dark(#6b3fa0, #b79ae0);
}
Which replaces the shape I’ve used since 2019: a block of tokens, then a second block overriding every one of them under a dark selector. Two lists that have to be kept in step by a human, where forgetting one line produces a single unreadable element that nobody notices for a month.
Now each token is one line and both values are on it, next to each other, which turns out to matter for a reason I didn’t anticipate: you can see the pair. Choosing a dark counterpart while looking at the light one is a different and better exercise from maintaining two lists.
The gain isn't fewer lines. It's that both values sit next to each other, so choosing the dark one becomes a decision about a pair rather than an entry in a second list.
The toggle still works
The obvious worry: this follows the operating system, and plenty of sites need a manual toggle, including this one.
It works, and the mechanism is nicer than what I had. light-dark() resolves against the element’s color-scheme, so the toggle sets that rather than swapping a token block:
:root { color-scheme: light dark; } /* follow the OS */
[data-theme="light"] { color-scheme: light; } /* the toggle overrides */
[data-theme="dark"] { color-scheme: dark; }
Three lines. Every token follows automatically, and so do the scrollbars and the form controls, which under the old arrangement required a separate color-scheme declaration anyway that I always forgot.
The existing script doesn’t change at all. It still stamps an attribute on the root element before first paint and still persists a choice. Everything downstream of that attribute got shorter.
Scoping a subtree, which fixes the logo row
The bit I’m most pleased about, because it solves something I explicitly gave up on in 2019.
The problem then was a row of partner logos supplied as PNGs with white backgrounds. On a dark page they’re a row of glowing white rectangles. My solution at the time was to give that section a permanently light background and feel bad about it, which was correct and looked like a patch.
Because light-dark() resolves per element, you can put a scheme on a subtree and everything inside it, including tokens, resolves to that scheme:
.logo-strip {
color-scheme: light; /* this section is light in both themes */
background-color: var(--surface);
color: var(--text);
}
That’s the same patch, expressed as a property of the section rather than as a set of hardcoded exceptions, and it now covers the borders and the muted text and anything else inside without me listing them. Same trick works for a code panel that should stay dark in both themes, or a callout that’s deliberately inverted.
Where it doesn’t reach
It’s colors only. It’s not a general “two values depending on scheme” function, so it does nothing for a border width, a shadow, or an image.
And every non-color problem from the 2019 post is exactly where it was. Images with baked-in white backgrounds. Screenshots. Whatever a client has pasted into the CMS with inline styles on it. Shadows that don’t read against dark surfaces and need to become borders. The scoping trick above helps with the first two more than I expected, and it’s containment rather than a fix.
Support, in February
Firefox has had it since November. Chrome ships it next month. Safari is expected later in the year.
So this isn’t something to rely on for client work yet, and the transitional position is genuinely worse than either end: you write the light-dark() version and keep the duplicate block underneath as a fallback, which is more code than you started with.
Which is why I’ve done it here, on my own site, where the downside of being early is that somebody sees the light theme. On client work I’ll wait until the fall and then delete a lot of CSS in one satisfying afternoon.
Set color-scheme today
Set color-scheme today. The rest of this can wait for Safari.