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.

Primary references