Six kinds of white
The stylesheet knew every color it used and had no idea what any of them were for.
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, so I’ll make a second one and swap them at the root.
It took two days. The colors were maybe an hour of that. I spent the rest finding out that the stylesheet had no idea what any of its own colors meant.
Flip it and reverse it
I tried inverting the values first because everybody does. 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 an unsettling spot to be in when you’re the person who’s supposed to know why.
Pure white text on pure black is really unpleasant to read. The glyphs bloom and the edges go soft. 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. That comes down to physiology, not 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. So the brand color in dark mode is not the brand color. That’s 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 did kill the half-day estimate inside an hour. The fixes themselves were an hour or two of nudging numbers around, though.
What is this white for?
The two days went into a search. I searched the project for #fff and got 61 results.
Between them, those results meant 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 and 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 its dark equivalent because white was never a fact about the design. It’s a coincidence. Six unrelated decisions landed on the same hex code. Nothing in the code says which is which.
A dark mode turns out to be an audit instead of a palette. You go through every color in the codebase and decide what it was for. That information doesn’t exist anywhere, not in any file and not in anybody’s head. I think nobody writes it down because the value is sitting right there in the declaration, looking like it explains itself.
A token gesture
A little embarrassingly, this is 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. Somebody types a name in place of the decision and that’s 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. Custom properties do the work here because they resolve on the page at runtime, after the stylesheet has loaded. That matters because the answer depends on a setting somebody made on their own laptop. A preprocessor 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. That part 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. The usual casualty is muted text. 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 it, and then found three of those in the dark one.
The things you don’t control
Images are where the tidy system runs into the real client.
Photographs are fine, mostly. 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. Every client has a few of those. They sit in a row under a heading that says “Our Partners,” white rectangles on a dark page that look 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. It’s the good version of all this because it reads a preference the person already set on their own computer and never has to think about again. I set mine on the Mac I switched to at work last year and haven’t thought about it since.
/* 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 but neither has released it. So in practice a toggle on the site is still what most people will 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. The choice persists, so it wins next time.
I’d guess 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 (mostly made by people who’ve 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. Here the honest range 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. 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 something the code can say now instead of something it can only be. 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.