color-scheme and the white flash
On the property that costs nothing and nobody writes, the function that puts both theme values on one line, and the row of logos I gave up on in 2019.
A client emailed to say their site flashes white when it loads.
I couldn’t reproduce it. Loaded it on staging, loaded it on production, throttled the connection down to something cruel, sat there with paint flashing turned on in DevTools watching rectangles go green. Nothing. So I did the thing you do when you’re fairly sure the person is wrong, which is ask very politely for a browser version and a screen recording.
Works on my machine, at 2 pm
They were right. I was testing in the afternoon with my laptop in light mode.
I got it that night on my phone, in bed, lights off. Tap a link, white rectangle, then the dark theme lands on top of it. Every single load. It’s the visual equivalent of somebody flipping the overhead light on for a beat, and once you’ve seen it you see it on half the web.
There was a second thing on that project’s list, sitting there since the fall, which I’d have told you was unrelated. The state dropdown on checkout was unreadable. Tap the <select>, get a white panel with pale gray options on it, in the middle of a page that is otherwise dark. I had it filed in my head under Chrome being weird about native controls 🤷🏼♀️
Both of those turned out to be the same missing declaration.
Two words, and they’re free
:root {
color-scheme: light dark;
}
That tells the browser the page can be rendered in either scheme, and everything the browser draws for itself then follows the preference the person already set on their operating system instead of defaulting to light. Form controls. Scrollbars. Spellcheck underlines. The <select> popup my client couldn’t read. And the canvas, which is the color the viewport gets painted before a single one of your own rules applies.
That last one is the flash.
The CSS version takes effect when the stylesheet does, so if you want it as early as the browser can possibly have it there’s <meta name="color-scheme" content="light dark">, which gets read while the head is still being parsed. I put in both.
color-scheme has been in all 3 engines since the start of 2022. I built my first dark theme back in 2019 and wrote a whole post about building it that spring, including a section called “the things you don’t control,” and the browser’s own furniture belonged in that section the entire time. I never typed the line once.
So go and set it today on anything with a dark theme, whatever else you take from this. The rest of the post is about a function that probably isn’t in your browser yet.
Two hexes on one line
light-dark() takes two colors and resolves to the first under a light scheme and the second under a dark one, and the scheme it reads is the one I’d just finished setting.
: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 written since 2019 and reproduced verbatim in that post, a block of tokens followed by a second block underneath overriding every one of them under a dark selector. Two lists kept in step by a human, where missing one line produces exactly one unreadable element that nobody reports for a month, or reports as “the state dropdown is unreadable.”
I didn’t expect that you can see the pair. Choosing a dark counterpart while looking at the light one, on the same line, is a different exercise from maintaining 2 lists 40 lines apart, and my sense is it’s a better one. The muted grays are where I always get it wrong. That’s the kind of wrong the contrast number is no help with, so anything that puts the two values next to each other while I’m picking them is worth having.
What about the toggle?
The objection is obvious and it was mine: this follows the operating system, and every dark theme I’ve built also has a switch on it, because clients ask for one and because what somebody wants system-wide and what they want on one site are not always the same thing.
It works, and the mechanism is shorter than the one I had. light-dark() resolves against the element’s own color-scheme, so the toggle sets a scheme instead of swapping out a block of values.
/* follow the operating system */
:root { color-scheme: light dark; }
/* the toggle overrules it */
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
Three lines. Every token follows, and so do the scrollbars and the form controls, which under the old arrangement wanted a color-scheme declaration of their own that I always forgot to keep updated. The script doesn’t change at all. It still stamps an attribute on the root element before first paint and still saves the choice. Everything downstream of that attribute got shorter.
The logo row I gave up on
This is the part I’m actually pleased about, because it retires a patch I’ve been a bit embarrassed by since 2019.
Partner logos, supplied as PNGs with white backgrounds, a whole row of them, the way every client has. On a dark page they’re a row of glowing white rectangles. In 2019 I gave that section a permanently light surface, and the reason I gave in that post was that it was late in the week.
light-dark() resolves per element, and a custom property gets resolved where it’s used instead of where it’s declared, so a scheme set on a subtree takes every token inside that subtree with it.
.logo-strip {
/* light in both themes, tokens included */
color-scheme: light;
background-color: var(--surface);
color: var(--text);
}
Same patch, written as a property of the section instead of as a list of hardcoded exceptions, and it now covers the border and the muted caption and whatever else ends up in there without me naming them one at a time. The same trick keeps a code panel dark in both themes, which is the version of it I expect to get the most use out of.
Colors only, I’m afraid
It’s a color function and nothing else. There’s no general “two values depending on the scheme” version of it. So it has nothing to say about a border width, or a shadow that needs to become a border on dark, or an image.
And every non-color problem from the 2019 post is exactly where I left it. Screenshots with white chrome baked around the edges. Whatever somebody has pasted into the CMS with inline styles on it, which they will. The scoping trick reaches further into that pile than I thought it would. It’s containment instead of a fix.
One browser, again
Firefox has had it since November. Chrome’s next release has it in. Safari sometime after that, I assume lol
So it goes nowhere near client work yet, and the transitional position is worse than either end of it, because you write the light-dark() version and keep the duplicate block underneath as a fallback, and now there are 3 lists to keep in step instead of 2.
I converted one token file anyway, on a branch, purely to see what it looked like. 60-something lines came out as 30-something, and that branch is still sitting there unmerged. There’s a branch like it for :has() from when that was Safari and nothing else. It sat there most of two years before it was worth opening again, so I’d guess this one lands somewhere around the fall.
What actually went out
One declaration and a meta tag, and both tickets closed behind them.
I only went looking because I was excited about the new thing. I keep turning that over. The property that closed both bugs has been in all 3 engines since 2022 and does nothing but state one fact about the page. I had never typed it. I went and looked at the other client sites here with a dark theme on them afterwards, and none of them had it either.
Which isn’t really a knowledge problem, I don’t think. The flash only exists for somebody whose operating system is dark and whose eyes have already adjusted to a dark room, and that is never me at a desk at 2 pm. When it does get reported it arrives worded as something else, like a dropdown being unreadable, or a page feeling slow, or a client saying the site looks cheap and not being able to tell you why.