</> HTML5Advent
ENFRESDEITPT

// css

The CSS light-dark() function: two themes on one line, and the one thing it needs first

light-dark() takes two colours and picks the right one for the active scheme. It removes most of a dark-mode stylesheet, and it does nothing at all unless color-scheme is declared. What it replaces, where it stops, and how it degrades.

A rocker light switch on a rough painted wall, lit from the upper right so that half the wall is bright and a hard diagonal shadow covers the lower left corner

Dark mode in CSS has meant the same thing for years: write your colours, then write them all again inside @media (prefers-color-scheme: dark). Two blocks, two places to forget, and a stylesheet where every colour appears twice.

light-dark() collapses that into one line per colour.

What it looks like

:root {
  color-scheme: light dark;
}

body {
  background: light-dark(#ffffff, #14161a);
  color: light-dark(#1a1c20, #e6e8ea);
}

The function takes two colour values and returns the first when the used colour scheme is light, the second when it is dark. No media query, no duplicate rule block, no second place to update when a colour changes.

The line without which nothing happens

color-scheme: light dark is not optional, and its absence is the single reason people report the function does not work.

light-dark() reads the used colour scheme of the element. That value is computed from the color-scheme property, and its initial value is normal, which resolves to light. So without that declaration on :root, the function faithfully returns its first argument, every time, in every browser, including dark ones.

Nothing errors. The page simply looks like the light theme and the code looks correct, which is the worst combination for debugging.

Three white rocker light switches mounted on a dark polished wooden panel, two side by side above and one below, a shaft of daylight falling across the wood

Three separate switches on one panel. The function is the equivalent of wiring both states into a single switch, but only after the panel itself has been told that two states exist.

What it does not replace

light-dark() returns a colour. That is its entire scope, and it is easy to forget once the colours are done.

A different hero image per scheme, a border that exists only in dark mode, an icon swapped for a lighter variant, an element hidden in one theme: none of these are colours, so all of them still need @media (prefers-color-scheme: dark). The media query does not disappear from your stylesheet, it just stops carrying the colour palette.

It also does not give you a manual theme toggle. A user who wants dark mode on a device set to light is choosing something the operating system did not say, and that choice has to live somewhere, in a class or a data attribute on the root element. Setting color-scheme from that attribute is what makes light-dark() follow a toggle rather than only the system.

How it fails, and it fails well

In a browser without support, the declaration containing light-dark() is invalid at parse time and is dropped. The property keeps whatever value it had before, which makes the fallback a single line placed above:

body {
  background: #ffffff;
  background: light-dark(#ffffff, #14161a);
}

No @supports needed. Old browsers read the first line and ignore the second; current ones apply both and the second wins.

Where it genuinely pays off

Not on body, where you had one media query anyway, but on a palette of custom properties:

:root {
  color-scheme: light dark;
  --surface: light-dark(#ffffff, #14161a);
  --surface-raised: light-dark(#f6f7f8, #1c1f24);
  --text: light-dark(#1a1c20, #e6e8ea);
  --text-muted: light-dark(#5a6068, #9aa1a8);
  --border: light-dark(#e2e5e8, #2a2e34);
}

Every component then uses var(--surface) and knows nothing about themes. Adding a colour is one line instead of two, and the two halves of a pair can never drift apart because they are written together.

That last point is the real gain. The duplicated-block approach fails slowly: someone adjusts a light colour, forgets the dark block, and the dark theme degrades one commit at a time until it looks neglected. Writing both values on the same line makes that particular mistake impossible.

Two things worth checking before shipping

Form controls. Declaring color-scheme also tells the browser to render native widgets, scrollbars, checkboxes and date pickers, in the matching scheme. That is usually what you want, and it is a visible change on pages that previously left them light.

Contrast in both directions. A pair that passes contrast in light mode can fail in dark, and the function makes it easy to write a pair without checking the second half. Test both, because light-dark() removes the duplication, not the responsibility.

Related reading: the CSS contrast-color() function and CSS cascade layers.

Frequently asked questions

What does the CSS light-dark() function do?
It takes two colour values and returns the first when the used colour scheme is light and the second when it is dark. It lets a single declaration carry both themes, so a stylesheet no longer needs a duplicate block inside a prefers-color-scheme media query.
Why is light-dark() not working?
Almost always because color-scheme has not been declared. The function reads the used colour scheme of the element, and without color-scheme: light dark on :root that value stays light, so light-dark() silently returns its first argument and everything looks like the light theme.
Does light-dark() replace prefers-color-scheme?
For colours, largely yes. For anything that is not a colour it does not, because the function only returns colour values. A different image, a different border radius or a hidden element per scheme still needs the media query.
What happens in a browser that does not support it?
The whole declaration is dropped at parse time, so the property falls back to whatever was declared before it or to its initial value. Putting a plain colour on the line above gives you a working fallback with no feature query.
Can I use light-dark() with custom properties?
Yes, and it is where the function pays off most: define your palette once as custom properties whose values are light-dark() pairs, then use those properties everywhere. Changing a theme colour becomes a one line edit rather than two.