The CSS light-dark() function takes two colours and returns the first when an element’s used colour scheme is light and the second when it is dark. Set color-scheme: light dark on :root, write color: light-dark(#1a1a1a, #f2f2f2), and the page follows the visitor’s system theme with no prefers-color-scheme media query at all.
It has been Baseline since May 2024 (Chrome and Edge 123, Firefox 120, Safari 17.5), so for colours you can use it on production sites today. What makes it useful is what it reacts to. It reads the color-scheme of the element, which you control, and not the operating system setting directly, which you do not.
Why is the light-dark() function not working?
Usually because color-scheme is not set. MDN is blunt about it: the function picks its value from the used colour scheme, and an element whose scheme is normal (the initial value) is treated as light. You get the first colour forever, whatever the OS says.
The fix is one declaration:
:root {
color-scheme: light dark;
}
That line tells the browser the page can render in either scheme and lets it choose from the user’s preference. color-scheme is inherited, so everything below :root picks it up. It also does work you would otherwise do by hand: the canvas background, scrollbars, form controls and spellcheck underlines all switch to their dark defaults. If you have ever shipped a dark page with a glaring white <select> dropdown, this is the property that was missing.
Add the matching meta tag in the <head>, before your stylesheet, so the browser knows the page supports dark before any CSS arrives and does not flash a white canvas on load:
<meta name="color-scheme" content="light dark">
How light-dark() replaces prefers-color-scheme
The old pattern declared every token twice: once for light, and again inside a media query for dark.
:root {
--text: #1a1a1a;
--surface: #ffffff;
--accent: #0b6e69;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #f2f2f2;
--surface: #121417;
--accent: #5cc8c1;
}
}
With light-dark() each token lives on one line, and the light and dark values sit next to each other where a reviewer can compare them:
:root {
color-scheme: light dark;
--text: light-dark(#1a1a1a, #f2f2f2);
--surface: light-dark(#ffffff, #121417);
--accent: light-dark(#0b6e69, #5cc8c1);
}
body {
color: var(--text);
background-color: var(--surface);
}
a {
color: var(--accent);
}
Because these custom properties are unregistered, var(--text) drops the whole light-dark(...) expression into body, and it resolves against the colour scheme of the element that uses it. So a token can return a different colour in different parts of the same page, which a media query cannot do.
Forcing one scheme on part of the page
color-scheme can be set on any element, and light-dark() follows it. A permanently dark site footer, or a code sample panel that should always look like an editor, is one declaration:
.site-footer {
color-scheme: dark;
}
Every light-dark() value inside the footer, including the ones coming through your tokens, now returns its dark colour, and the footer’s form controls go dark with it. With prefers-color-scheme you would have had to duplicate the dark token block under a class, because a media query asks about the user’s device and cannot be scoped to a subtree.
only exists too. color-scheme: only light tells Chrome’s Auto Dark Theme not to invert that element, which is useful for a brand logo block or a chart whose colours carry meaning.
How do I build a light/dark/system toggle with light-dark()?
Because the function follows color-scheme, a theme switch is just a different value for that property on the root. Three states, three rules:
:root {
color-scheme: light dark; /* "system": follow the OS */
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
No token is redefined anywhere. The stored preference only has to land on <html> before first paint, so put a small inline script in the <head>:
<script>
try {
const theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") {
document.documentElement.dataset.theme = theme;
}
} catch {
// Storage blocked: fall back to the system setting.
}
</script>
The toggle button sets document.documentElement.dataset.theme and writes the same value to localStorage; choosing “system” deletes the attribute and the key. Since the native controls follow color-scheme as well, an explicit dark choice also darkens scrollbars and inputs. With the media-query approach you only got that if you remembered to set color-scheme by hand as well.
What light-dark() cannot do
It switches colours, and as of the current releases images. Nothing else. If dark mode needs a lighter font weight, a different box-shadow offset or a thinner border, light-dark() will not express it. The colour inside a shadow can use it; the lengths cannot. For those you still need @media (prefers-color-scheme: dark), and if you have a manual toggle you also need a matching [data-theme="dark"] selector, because the media query does not know about your attribute. Keep that list short. In most design systems it is a handful of declarations.
Images: new, check before relying on it
The function now also accepts two <image> values, so a background pattern or a gradient can switch with the scheme:
.hero {
background-image: light-dark(
url("/img/grid-light.svg"),
url("/img/grid-dark.svg")
);
}
That shipped much later than the colour form: Chrome and Edge 150, Firefox 150 and Safari 27, per Can I use. As of October 2026 Safari 27 has only been out a few weeks, so plenty of visitors are still on browsers that will throw the declaration away. The two arguments must be the same type (two colours or two images, never one of each), and the image form added a none keyword, which Bramus points out gives you a clean feature test:
@supports (background-image: light-dark(none, none)) {
.hero {
background-image: light-dark(
url("/img/grid-light.svg"),
url("/img/grid-dark.svg")
);
}
}
For icons, fill: currentColor on an inline SVG is still simpler than swapping two files.
Fallbacks for older browsers
For a direct declaration the usual cascade fallback works. A browser that does not understand light-dark() drops the second line and keeps the first:
body {
background-color: #ffffff;
background-color: light-dark(#ffffff, #121417);
}
Custom properties behave differently, and this one catches people. An unsupported value inside --surface: light-dark(...) is not rejected when the property is declared, because the browser does not check custom property values at that point. It only fails when var(--surface) is substituted, and a declaration that is invalid at that stage falls back to the property’s inherited or initial value, not to the line above it. In practice that means a transparent background, not your light colour. If you still support browsers older than the May 2024 releases, either keep the media-query token block for them or put plain-colour fallbacks on the handful of elements where a missing colour would hurt.
Most projects can skip this. Check your own analytics, not a global figure. If pre-2024 browsers are a rounding error, ship light-dark() without a fallback.
What we would do
On any new build, or any refactor of an existing theme, use light-dark() for every colour token, set color-scheme: light dark on :root with the matching meta tag, and drive a manual toggle by changing color-scheme on the root, leaving the tokens alone. Keep prefers-color-scheme only for the few non-colour tweaks. Hold off on the image form for anything visible until Safari 27 has a few months of adoption, and gate it behind @supports when you do use it. If you also want components that adapt to their container and not the viewport, our guide to container queries versus media queries covers the other half of that shift, and CSS @scope pairs well with per-section color-scheme overrides.
Theme work like this is a regular part of the responsive web design projects we take on at Whoooop, usually as part of tidying a token system so it can carry more than one look without doubling its size.