Six kinds of white
The stylesheet knew all of its colors and none of their meanings.
A client updated their laptop over the winter, watched macOS go dark, and asked what it would take for their site to do the same.
“Half a day,” I said. I have a palette, I’ll make a second palette, I’ll swap them at the root.
It took two days, and the colors were maybe an hour of that. The rest went on finding out that the stylesheet had no idea what any of its own colors meant.
Flip it and reverse it
I first tried inverting the values, because everyone tries it. Dark background, light text, flip the grays around the middle. It looked bad in a way I couldn’t articulate for a while, which is its own kind of unsettling when you’re the person who is supposed to know why.
Pure white text on pure black is genuinely unpleasant to read. The glyphs bloom, the edges go soft, and a long paragraph of it seems to vibrate, more so if you’re a little astigmatic, which a lot of people are. Every dark interface I actually like uses an off-black somewhere around #121212 to #1a1a1a with a light gray doing the text, and the reason for that is physiology instead of taste.
The brand color was worse. A saturated purple that looked confident on white was practically fluorescent on near-black, buzzing at its own edges. Saturated colors have to be lightened and desaturated before they’ll sit on a dark surface, which means the brand color in dark mode is not the brand color, which is a conversation with a designer and sometimes with a brand guidelines PDF that specifies the purple in Pantone and has Opinions about clear space.
All of that was small potatoes. It killed the half-day estimate inside an hour, but the fixes themselves were an hour or two of nudging numbers around.
What is this white for
The two days went on a search. I searched the project for #fff and got 61 results.
Those results meant, between them, at least six different things: the page itself, a raised card sitting on the page, text on a dark button, a divider line at low opacity, an icon fill, and the inside of an input.
.page { background-color: #fff; }
.card { background-color: #ffffff; }
.button--primary { color: #FFF; }
.rule { border-top-color: #ffffff26; }
.icon { fill: #fff; }
.input { background-color: #fff; }
That isn’t even all of the spellings. Searching for #fff catches #ffffff and #FFF and the ones with an alpha stuck on the end. It walks straight past white and rgba(255, 255, 255, 0.12), both of which were in there too.
In dark mode some of those become near-black, some become a slightly lighter gray, one stays white because it’s still sitting on a dark button, and one becomes a very low-opacity white with no relationship to any of the others.
So there’s no transformation from white to the dark equivalent, because white was never a fact about the design. It’s a coincidence. Six unrelated decisions landed on the same hex code, and nothing in the code says which of them is which.
Which makes a dark mode an audit instead of a palette. You go through every color in the codebase and decide what it was for, and that information doesn’t exist anywhere, in any file, in anybody’s head. I think the reason nobody writes it down is that the value is sitting right there in the declaration looking like it explains itself.
Nobody decided, again
Which makes this, a little embarrassingly, the same problem as a status I named in a few seconds and didn’t look at again for eight months. The vocabulary is never really the hard part. It’s that nobody ever decided what the thing was, and the name typed in place of the decision is what everybody ends up using.
You end up with a set of names for jobs instead of names for colors.
:root {
--surface: #ffffff;
/* cards and panels, anything sitting on top of the page */
--surface-raised: #f6f6f4;
--text: #1b1b1b;
--text-muted: #5f5f5f;
--border: #e2e2df;
--accent: #6b3fa0;
/* the label on an accent button */
--on-accent: #ffffff;
}
[data-theme="dark"] {
--surface: #141416;
/* lighter than the page, not darker */
--surface-raised: #1e1e22;
--text: #e8e6e3;
--text-muted: #a2a09c;
--border: #2e2e33;
/* lighter and less saturated than the light one */
--accent: #b79ae0;
/* and now that label is dark */
--on-accent: #17131f;
}
Once that exists the theme is a block of values and the media query is three lines. The custom properties are doing the work here for the same reason I went on about when they turned up in 2016, which is that they resolve on the page, at runtime, after the stylesheet has loaded, because the answer depends on a setting somebody made on their own laptop. A Sass variable finished its job on my machine 20 minutes ago.
Three things that go the other way
Elevation inverts, sort of. On a light interface a raised surface is lighter than the page with a shadow underneath it. On a dark interface a shadow against near-black is invisible, so a raised surface is lighter than the page as well. Higher is still lighter, which is what survives. The direction away from the page color flips, so every modifier I’d written as a darken() became one step up a surface scale instead.
Shadows mostly stop working. Anything using a drop shadow to hold two elements apart needs a border in dark mode. I came out of that with a --border token that’s nearly invisible in light mode and doing real structural work in the dark one.
Contrast has to be rechecked in both directions. A pairing that passes on white can fail on near-black, and the usual casualty is muted text, since a gray that sits comfortably as a secondary on white is usually too dim on near-black. I checked the light theme, felt pretty smug about myself, and then found three of those in the dark one.
The things you don’t control
Images are where the tidy system meets the real client.
Photographs are fine (mostly, and the exception is anything shot on a white studio background, which in ecommerce is every product photo there is!) Logos supplied as a PNG with a white background are not fine, and every client has a few of those, sitting in a row under a heading that says Our Partners, white rectangles on a dark page looking like a bug somebody is about to email about. The fix is either transparent versions, which means asking somebody for files and then waiting for them, or giving the logo row a permanently light surface of its own. I did the second one, because it was late in the week.
SVG is the good outcome here. An inline SVG that uses currentColor for its fills just works, in both modes, forever, for free. I’ve started asking for icons that way as a matter of course.
The setting they already made
prefers-color-scheme came out in Safari 12.1 yesterday, and it’s the good version of all this, because it reads a preference the person already set on their own computer and then never has to think about again.
/* the default, and what somebody with no opinion gets */
:root { /* the light values */ }
/* what their operating system already knows about them */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* the dark values */ }
}
/* what they picked here, which wins */
[data-theme="dark"] { /* the dark values */ }
For now that’s Safari and nothing else. Firefox and Chrome both have it in progress and neither has released it, so in practice a toggle on the site is still the thing most people will actually use. That’s why the values hang off a data-theme attribute and not only off the query. Attribute for the choice, query for the default, and the choice persists so it wins next time.
My guess is that in a year the query is everywhere and the toggle becomes the override instead of the mechanism. I’d like to be around for the version of this where nobody has to draw a little sun and a little moon.
What I should have said
Half a day was an honest answer to the question I heard, which was about colors. The question I’d actually been handed was to go back through years of decisions nobody wrote down, made mostly by people who have since moved on, and say out loud what each one was for.
I don’t know how to estimate that in a room, in the couple of seconds you get after somebody asks. Everything I believe about estimates says the honest answer is a range with a long tail on it, and the honest range here was half a day if the palette is organized and a week if it isn’t. I had no way of telling which one I was looking at until I ran the search. So now I run the search first, and the number it gives me isn’t the estimate. But it does tell me which of the two answers I’m about to say out loud.
The tokens outlive the dark mode, which is the part I didn’t expect to care about. The names are still in there. A raised surface is a thing the code can say now instead of a thing it can only be, and the next time somebody wants a variant, a high-contrast mode, a white-label tenant, the seasonal one where everything goes red, it’s another block of values instead of another week of asking what a white was for.