The familiar dark-mode stylesheet defines light colors, opens a prefers-color-scheme: dark query, then repeats every token with a dark value. It works. It also separates each pair of related colors, so changing one theme without checking the other is surprisingly easy.
light-dark() accepts a light value first and a dark value second. The browser selects one based on the color scheme used by that element.
Set color-scheme before using light-dark()
:root {
color-scheme: light dark;
color: light-dark(#20231f, #f1f4ec);
background: light-dark(#fbf8f2, #121713);
}
Without color-scheme: light dark, the normal scheme is used and the first value wins. The function does not inspect prefers-color-scheme on its own. It responds to the element's used color scheme.
The property also tells the browser that both schemes are supported. Built-in controls, scrollbars, form fields, and system colors can follow the selected scheme instead of staying bright inside a dark page.
Keep each semantic color pair together
The function is most useful in semantic tokens. Components can keep using --surface and --text without knowing how theme selection works.
:root {
color-scheme: light dark;
--page: light-dark(#fbf8f2, #121713);
--surface: light-dark(#ffffff, #1b221c);
--text: light-dark(#20231f, #edf3e9);
--muted: light-dark(#62685f, #abb5a7);
--border: light-dark(#c8d1c4, #526052);
}
.card {
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
}
This removes the duplicate token block, not the need for good token names. Pair colors by purpose. If several components copy literal pairs, the theme still becomes difficult to maintain.
A component can choose its own used scheme
Because color-scheme inherits, a subtree can preview either theme without rewriting its tokens. This works well in documentation, settings screens, and side-by-side design reviews.
.theme-preview {
color: var(--text);
background: var(--surface);
}
.theme-preview[data-theme="light"] {
color-scheme: light;
}
.theme-preview[data-theme="dark"] {
color-scheme: dark;
}
The tokens inside each preview resolve again for its local scheme. A media query cannot express this as neatly because both previews exist under the same system preference.
A branded section that must remain light can set color-scheme: light. Use that deliberately. Forcing the whole document to ignore a user's preference is usually a worse experience than supporting it.
A saved theme toggle may still need JavaScript
If the site only follows the operating-system preference, CSS is enough. A toggle that overrides and remembers that preference still has application state. JavaScript can set one attribute; it no longer needs to swap every color class.
const root = document.documentElement;
const toggle = document.querySelector("#theme-toggle");
toggle.addEventListener("change", () => {
root.dataset.theme = toggle.checked ? "dark" : "light";
});
:root {
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
Add storage if remembering the choice is part of the product. Apply the saved scheme in the document head before the page paints to avoid a flash of the wrong theme.
Fallbacks do not solve contrast for you
light-dark() has worked in current major browsers since 2024. If older browsers are still in your support matrix, put a plain value before it. A browser that cannot parse the function keeps the earlier declaration.
.card {
color: #20231f;
color: light-dark(#20231f, #edf3e9);
background: #ffffff;
background: light-dark(#ffffff, #1b221c);
}
That fallback is light-only. If an unsupported browser must receive a complete dark theme, keep the old media-query overrides inside a support strategy tested against your browser matrix.
The function chooses a value; it does not calculate readable contrast. Test each foreground and background pair in both schemes, including focus indicators, disabled controls, selected text, and visited links. Forced-colors mode may replace author colors, so avoid fighting it with forced-color adjustments unless a control genuinely needs them.
The current CSS Color Level 5 draft also defines an image form of light-dark(). Its support can differ from the color form. Use an @supports query before relying on theme-dependent gradients or URLs in production.
