← All writing
Craft · · 7 min

Dark mode is a data problem

Inverting doesn't work, because the stylesheet doesn't know what its own values mean.

CSS Accessibility

The media query for this landed in Safari yesterday, hence the timing, but the actual work happened over the winter on a client project where somebody had seen the operating system go dark and wanted the site to match.

“How long for a dark mode?” Half a day, I said, thinking: I have a color palette, I’ll make a second one, I’ll swap them.

It was not half a day. And almost none of the time went on choosing colors.

Inverting doesn’t work

The first thing I tried, because everyone does, was flipping the values. Dark background, light text, invert the grays.

It looked awful, and not in a way I could immediately articulate. Pure white text on pure black is genuinely unpleasant to read: the glyphs bloom and the edges get fuzzy, particularly for anyone slightly astigmatic, and a long paragraph of it vibrates. Every dark interface I actually like uses an off-black around #121212 to #1a1a1a and a text color that’s a light gray rather than white, and the reason is physiological rather than aesthetic.

The brand color was worse. A saturated purple that looked confident on white was practically fluorescent on near-black, buzzing at the edges. Saturated colors need lightening and desaturating for 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.

None of that took long to fix. It just meant the naive plan was dead within about 40 minutes.

The stylesheet doesn’t know what its colors mean

Here’s the actual problem, and it’s why I’ve called this a data problem.

I searched the project for #fff and got 61 results. Those 61 instances meant, between them, at least six different things: the page background, a raised card surface, text sitting on a dark button, a divider line at low opacity, an icon fill, and the inside of an input.

In dark mode, some of those become near-black, some become a slightly lighter gray, some stay white because they’re still sitting on a dark button, and one becomes a very low-opacity white that’s a completely different value from the others.

There is no transformation from “white” to “the dark equivalent,” because “white” isn’t a fact about the design. It’s a coincidence of six unrelated decisions landing on the same hex code.

There's no dark equivalent of white, because white isn't a fact about the design. It's six unrelated decisions that happened to land on the same hex code.

So the work isn’t picking a second palette. It’s going through every color in the codebase and deciding what it was for. That is information nobody ever wrote down because the value was sitting right there and seemed self-explanatory.

Naming the meanings is the job

Which makes this, unexpectedly, the same post I wrote in January about naming things: the difficulty is not the vocabulary, it’s that nobody decided.

The output is a set of names for roles rather than for colors:

tokens.css
:root {
  --surface:        #ffffff;   /* the page */
  --surface-raised: #f6f6f4;   /* cards, panels, anything sitting on the page */
  --text:           #1b1b1b;
  --text-muted:     #5f5f5f;
  --border:         #e2e2df;
  --accent:         #6b3fa0;
  --on-accent:      #ffffff;   /* text on the accent, which is white in both modes */
}

[data-theme="dark"] {
  --surface:        #141416;
  --surface-raised: #1e1e22;   /* lighter than the page, not darker */
  --text:           #e8e6e3;
  --text-muted:     #a2a09c;
  --border:         #2e2e33;
  --accent:         #b79ae0;   /* lighter and less saturated than the light one */
  --on-accent:      #17131f;   /* and now the text on it is dark */
}
CSS

Once that exists, the theme is trivial and the media query is four lines. Getting there took two days on a medium-sized site, all of it spent reading rules and asking “what is this white for.”

The custom properties are doing the work here for exactly the reason I banged on about back in 2016: these have to be resolved on the page, at runtime, after the CSS has shipped, because the answer depends on a setting the user made on their laptop. A preprocessor variable finished its job 20 minutes ago on my machine.

The three that caught me out

Elevation inverts. On a light interface, a raised surface is lighter than the page and has a shadow under it. On a dark interface, a shadow against near-black is invisible, so raised surfaces are indicated by being lighter than the page too. Which means the relationship between “higher up” and “lighter” is the same, but the relationship between “raised” and “away from the page color” flips direction, and every modifier I’d written as darken() had to become “one step further up the surface scale.”

Shadows mostly stop working. Anything relying on a drop shadow to separate two elements needs a border in dark mode instead. I ended up with a --border token that’s nearly invisible in light mode and doing real structural work in dark.

Contrast has to be rechecked, both ways. A pairing that passes on white can fail on near-black and vice versa, because contrast ratio isn’t symmetric in how it feels even where the number holds. Muted text is the usual casualty: a gray that’s a comfortable secondary on white is often too dim on dark.

The things you don’t control

Images are where a tidy system meets reality.

Photographs are fine. Logos supplied as a PNG with a white background are not, and every client has at least four of those, on the “our partners” section, sitting in a row of white rectangles on a dark page looking like a mistake. The fix is either transparent versions, which means asking someone for files, or giving the logo row a permanently light surface. I did the second one, because it was Thursday.

SVG is the good outcome here. An inline SVG that uses currentColor for its fills just works, in both modes, forever, for free, and I’ve started asking for icons that way as a matter of course.

Honoring the setting, this month

The media query is the good version, because it reads a preference the person already set at the operating system level and never has to think about again:

theme.css
@media (prefers-color-scheme: dark) {
  :root { /* the dark token values */ }
}
CSS

As of this week that’s Safari and nothing else. Firefox and Chrome are both working on it and neither has shipped.

So in practice, right now, a toggle is still the thing that most people will actually use, which is why the tokens above hang off a data-theme attribute rather than only off the query. Attribute for the toggle, query for the default, and the toggle persists so it wins next time. It’ll be nicer in a year when the query is everywhere and the toggle becomes the override rather than the mechanism.

What the estimate is actually for

If somebody asks you for a dark mode, it is not about colors. It’s an audit of every color decision anybody has made on that codebase, most of which were made in a hurry and none of which were written down.

Which, per November, is exactly the sort of thing I should be quoting as a range with the reason attached, rather than saying “half a day” in a meeting because I was picturing the easy part.

Read similar posts
7 min

Contrast ratios lie

A page I'd checked properly, that passed every automated tool I own, was unreadable on my own phone in a beer garden in June, and the number had nothing to say about why.

6 min

Motion is not decoration

I built a parallax hero because a client asked for one, showed it to a colleague, and they asked me to close the tab, and then had a headache for the rest of the afternoon.