← All writing
Craft · · 6 min

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.

CSS

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.

tokens.css
:root { color-scheme: light dark; }
CSS

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.

tokens.css
: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);
}
CSS

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:

theme.css
:root                { color-scheme: light dark; }   /* follow the OS */
[data-theme="light"] { color-scheme: light; }        /* the toggle overrides */
[data-theme="dark"]  { color-scheme: dark; }
CSS

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:

logos.css
.logo-strip {
  color-scheme: light;            /* this section is light in both themes */
  background-color: var(--surface);
  color: var(--text);
}
CSS

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.

Read similar posts
6 min

Style queries

Five surface variants, four child elements, and 20 selectors that existed only to tell a child which parent it was in. Style queries delete the whole grid of them. That isn't quite the same as saying you needed them.

6 min

currentColor

I opened a button component with 11 color declarations in it, across four variants, and the correct number was one per variant, because six of those properties already default to the thing they were being set to.