Light and Dark Themes With light-dark(): One Declaration per Colour
The traditional way to ship light and dark themes is two blocks of custom properties: one on :root, one in @media (prefers-color-scheme: dark) or under [data-theme="dark"]. Every token appears twice, far apart in the stylesheet, and adding a colour means remembering to add it in both places. The light-dark() function collapses each pair into one declaration: --surface: light-dark(#ffffff, #0f172a). Combined with the color-scheme property, it lets a single rule decide which half applies, whether the choice comes from the operating system or a theme toggle. This page shows how the two fit together, how to wire a manual toggle, and the traps that make light-dark() appear not to work. It is part of Color & Theme Transitions in the CSS-Only Micro-Interactions & Animations guide.
Why pair the colours
A theme is a mapping from roles to colours. The role "surface" is white in light mode and near-black in dark mode; the role "muted text" is slate-600 in one and slate-400 in the other. Keeping both values of a role together makes the relationship reviewable at a glance — you can see that the dark muted text is lighter than the light one and judge whether its contrast is comparable. Splitting them across two blocks hides the relationship and invites drift: a token updated in the light block and forgotten in the dark one is the classic source of an unreadable dark mode.
light-dark() also removes a category of bug. With two override blocks, the dark theme depends on a media query or attribute being matched in the right place with the right specificity. With light-dark(), the decision is made by color-scheme, a single inherited property, so there is one switch to get right.
How the decision is made
color-scheme has two jobs. It tells the browser which schemes the page supports, which also themes form controls, scrollbars and the default canvas colour. And it determines the used scheme for each element, which is what light-dark() consults. With color-scheme: light dark, the operating-system preference picks; with color-scheme: dark, dark is used regardless of the preference. Because color-scheme inherits, setting it on :root covers the page, and setting it on a subtree creates a locally themed region.
The complete implementation
The page below defines tokens with light-dark(), follows the system preference by default, and lets a toggle override it by setting data-theme on the root.
<!doctype html>
<html lang="en" data-theme="system">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>light-dark() themes</title>
<style>
:root {
/* Default: follow the operating system. */
color-scheme: light dark;
/* Each token declared once, both themes side by side. */
--surface: light-dark(#ffffff, #0f172a);
--surface-alt: light-dark(#f1f5f9, #1e293b);
--text: light-dark(#0f172a, #e2e8f0);
--text-muted: light-dark(#475569, #94a3b8);
--border: light-dark(#cbd5e1, #334155);
--accent: light-dark(#4338ca, #a5b4fc);
}
/* A manual choice overrides the system by pinning color-scheme. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
body {
margin: 0;
padding: 1.5rem;
background: var(--surface);
color: var(--text);
font: 16px/1.6 system-ui, sans-serif;
}
.card {
max-width: 26rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-alt);
}
.card p { color: var(--text-muted); }
.card a { color: var(--accent); }
/* A permanently dark region inside any theme, e.g. a code sample. */
.code-panel {
color-scheme: dark;
background: var(--surface);
color: var(--text);
padding: 1rem;
border-radius: 8px;
}
</style>
</head>
<body>
<div class="card">
<h2>Invoice #1042</h2>
<p>Due in 14 days. <a href="/invoices/1042/">View details</a></p>
<pre class="code-panel">amount: 240.00</pre>
</div>
</body>
</html>
The toggle itself only needs to set data-theme on the root and remember the choice. A tiny inline script in the <head> should apply the stored value before first paint, or the page flashes in the system theme before switching:
<script>
// Runs before the body renders, so there is no flash of the wrong theme.
try {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') {
document.documentElement.dataset.theme = stored;
}
} catch (e) {}
</script>
The key technique: the token is computed where it is declared
The trap that catches almost everyone is custom-property evaluation. A custom property holding light-dark(#fff, #0f172a) is not resolved to a colour on :root and then inherited as that colour. Registered or not, the light-dark() value is resolved where it is used as a colour — but only if the property's value is still the unresolved function by then. For unregistered custom properties, the token is inherited as its specified text and resolved at the use site, against that element's color-scheme. That is exactly what makes the .code-panel work: it sets color-scheme: dark, and var(--surface) inside it resolves to the dark value even when the page is light.
The corollary is a warning about @property. If you register --surface with syntax: "<color>", its computed value is resolved to a concrete colour on the element where it is declared — :root — and descendants inherit that concrete colour. Nested scheme changes then stop working. Keep theme tokens that use light-dark() unregistered, and register only properties you need to animate, as discussed in Registered Properties and Type Safety.
Migrating an existing two-block theme
Most sites adopting light-dark() already have a working theme built from two blocks of custom properties. The migration can be done token by token without a big-bang rewrite.
Start by adding color-scheme to the root: light dark if the site follows the system, and the explicit value under each data-theme attribute if it has a toggle. This alone improves the page, because form controls and scrollbars now match the theme. Then take one token at a time: write its light and dark values into a single light-dark() declaration on :root, and delete the token from the dark block. The site looks identical at every step, because both mechanisms agree, which makes each step easy to verify with a screenshot diff in both themes.
Two kinds of token do not migrate. Values that are not colours — shadow offsets, image URLs, filter amounts — stay in the dark block or a media query, since light-dark() accepts only colours. And tokens that depend on other tokens with maths, such as a color-mix() of two themed colours, should be checked carefully: they work as long as both inputs are unregistered light-dark() tokens resolved at the same element, but a registered input will freeze to the root's scheme. When the dark block finally contains only non-colour values, the migration is complete.
Contrast in both themes
Pairing colours makes it easy to review contrast, but it does not do the review for you. Dark themes have their own traps: pure white text on pure black causes halation for some readers, and saturated brand colours that pass on white often fail on dark backgrounds, which is why the example's --accent lightens from indigo-700 to indigo-300 in dark mode. Check every token pair against the surfaces it sits on, in both themes, for WCAG 1.4.3 text contrast and 1.4.11 non-text contrast.
For users who need more, prefers-contrast: more can be layered on top: a media query that swaps the muted tokens for stronger ones in both schemes, still using light-dark() so the pairing is preserved.
Variation: animating the theme switch
Because light-dark() resolves to ordinary colours, a theme switch changes computed background-color, color and border-color values, and those can be transitioned. A short transition on the main surfaces turns the switch into a gentle crossfade rather than a flash.
body,
.card {
transition:
background-color 200ms ease,
color 200ms ease,
border-color 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
body, .card { transition: none; }
}
Transitioning every element is expensive and produces staggered-looking results, so apply it to the few large surfaces that dominate the view. The whole-page approach, including a view-transition-based wipe, is covered in Smooth Theme Switching Transitions.
Browser support
light-dark() is supported in current versions of Chrome, Edge, Firefox and Safari. color-scheme is supported in all of them and has been for longer. For older engines, declare the light colour as a plain value before each light-dark() token, or keep a @media (prefers-color-scheme: dark) block as a fallback; unsupported light-dark() declarations are ignored, leaving the earlier value in place. prefers-contrast is supported in Chrome and Edge 96+, Firefox 101+ and Safari 14.1+.
FAQ
How does light-dark() decide which colour to use?
It returns its first argument when the element's used color-scheme is light and its second when it is dark. The used color-scheme comes from the color-scheme property, which inherits, combined with the user's operating-system preference when color-scheme allows both.
Why does light-dark() always return the light colour?
Because color-scheme is not set, or is set to light only. light-dark() does nothing unless the element's color-scheme includes dark. Set color-scheme: light dark on the root to follow the system, or set it explicitly to light or dark from a theme toggle.
Can light-dark() be used for images or lengths?
The original function only accepts colours. It is designed for colour tokens: backgrounds, text, borders and shadows. Use a data attribute or a media query for anything else that changes between themes, such as images or shadow sizes.
Does light-dark() replace a custom-property theme system?
It complements one. Define each colour token once with light-dark(), and components keep referencing the token. The theme switch then becomes a single color-scheme change on the root rather than a second block of overrides for every token.
Related
- Color & Theme Transitions — the parent guide.
- Smooth Theme Switching Transitions — animating the moment of the switch.
- Container Style Query Theming — section-level themes with style queries.
- Cascade Layers for Reset and Tokens — where theme tokens live in the cascade.
Related articles
More pages in the same section.