Home / Articles / Color & Theming /

colortheming

The light-dark() function

Define light mode and dark mode colors in a single line — no media queries, no duplicated rulesets, no preprocessor.

The old way: media query duplication

Before light-dark(), supporting two color schemes meant writing every theme-sensitive declaration twice — once in the base styles and once inside a @media (prefers-color-scheme: dark) block. For large design systems, this doubled the amount of color-related CSS and made maintenance painful.

/* The old way — everything declared twice */
:root {
  --bg: oklch(0.97 0.005 260);
  --text: oklch(0.15 0.02 260);
  --border: oklch(0.85 0.01 260);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: oklch(0.13 0.02 260);
    --text: oklch(0.93 0.01 260);
    --border: oklch(0.25 0.03 260);
  }
}

/* Every new token requires edits in two places */

This pattern works, but it does not scale well. Adding a new color token means updating both the light block and the dark block, and they can drift apart over time.

How light-dark() works

The light-dark() function takes two arguments: a light-mode value and a dark-mode value. The browser picks the correct one based on the computed color-scheme of the element.

/* Required: declare that the page supports both schemes */
:root {
  color-scheme: light dark;
}

/* Now light-dark() works everywhere */
:root {
  --bg:     light-dark(oklch(0.97 0.005 260), oklch(0.13 0.02 260));
  --text:   light-dark(oklch(0.15 0.02 260), oklch(0.93 0.01 260));
  --border: light-dark(oklch(0.85 0.01 260), oklch(0.25 0.03 260));
  --accent: light-dark(oklch(0.50 0.22 265), oklch(0.72 0.19 265));
}

body {
  background: var(--bg);
  color: var(--text);
}

The color-scheme: light dark declaration is required. Without it, light-dark() always returns the light value. You can set color-scheme on :root for the entire page, or on individual elements for mixed themes.

Using light-dark() inline

You do not need custom properties to use light-dark(). It works directly in any property that accepts a color value. This is useful for one-off component styles where creating a token would be overkill.

.card {
  background: light-dark(oklch(0.99 0.003 260), oklch(0.19 0.02 260));
  border: 1px solid light-dark(oklch(0.88 0.01 260), oklch(0.28 0.03 260));
  box-shadow: 0 2px 8px light-dark(
    oklch(0 0 0 / 0.06),
    oklch(0 0 0 / 0.3)
  );
}

.card__title {
  color: light-dark(oklch(0.15 0.02 260), oklch(0.95 0.01 260));
}

.card__meta {
  color: light-dark(oklch(0.45 0.02 260), oklch(0.63 0.02 260));
}

This approach keeps light and dark values co-located. When you read the CSS, you immediately see both theme values without jumping between rule blocks.

Forcing a color scheme per element

Sometimes parts of a page need a fixed theme — a dark header on a light page, or a light modal on a dark page. Setting color-scheme on a specific element controls which value light-dark() returns for that subtree.

/* Page follows system preference */
:root {
  color-scheme: light dark;
}

/* This header is always dark, regardless of system preference */
.site-header {
  color-scheme: dark;
  background: light-dark(oklch(0.97 0.005 260), oklch(0.13 0.02 260));
  /* Always returns the dark value: oklch(0.13 0.02 260) */
  color: light-dark(oklch(0.15 0.02 260), oklch(0.93 0.01 260));
  /* Always returns: oklch(0.93 0.01 260) */
}

/* This aside is always light */
.light-panel {
  color-scheme: light;
  background: light-dark(oklch(0.99 0.003 260), oklch(0.19 0.02 260));
  /* Always returns the light value: oklch(0.99 0.003 260) */
}

This is much cleaner than overriding every custom property manually. The element declares its scheme, and all light-dark() calls within it respond accordingly.

Combining with custom properties and OKLCH

For design systems, the most powerful pattern combines light-dark() with custom property tokens and OKLCH values. This gives you theme-aware, perceptually consistent colors with minimal code.

:root {
  color-scheme: light dark;

  /* Surface tokens */
  --surface-0: light-dark(oklch(0.99 0.003 260), oklch(0.13 0.02 260));
  --surface-1: light-dark(oklch(0.96 0.005 260), oklch(0.17 0.02 260));
  --surface-2: light-dark(oklch(0.93 0.008 260), oklch(0.21 0.02 260));

  /* Text tokens */
  --text-primary:   light-dark(oklch(0.15 0.02 260), oklch(0.93 0.01 260));
  --text-secondary: light-dark(oklch(0.40 0.02 260), oklch(0.63 0.02 260));

  /* Interactive tokens */
  --interactive:       light-dark(oklch(0.50 0.22 265), oklch(0.68 0.19 265));
  --interactive-hover: light-dark(oklch(0.42 0.22 265), oklch(0.75 0.17 265));

  /* Border tokens */
  --border-subtle: light-dark(oklch(0.90 0.01 260), oklch(0.25 0.02 260));
  --border-strong: light-dark(oklch(0.75 0.01 260), oklch(0.40 0.03 260));
}

Notice the OKLCH lightness values are roughly inverted between light and dark: light-mode backgrounds are near L=1.0 while dark-mode backgrounds are near L=0.13. Text follows the opposite pattern. This symmetry makes the system easy to reason about.

Browser support and fallbacks

The light-dark() function has broad support in 2026: Chrome 123+, Safari 17.5+, Firefox 120+. For the rare case where you need to support older browsers, a fallback strategy is straightforward.

/* Fallback strategy for older browsers */
:root {
  /* Fallback: dark mode values (your primary theme) */
  --bg: oklch(0.13 0.02 260);
  --text: oklch(0.93 0.01 260);
}

/* Modern browsers override with light-dark() */
@supports (color: light-dark(red, red)) {
  :root {
    color-scheme: light dark;
    --bg: light-dark(oklch(0.97 0.005 260), oklch(0.13 0.02 260));
    --text: light-dark(oklch(0.15 0.02 260), oklch(0.93 0.01 260));
  }
}

/* Components just use the tokens — they don't care how they're defined */
body {
  background: var(--bg);
  color: var(--text);
}

The @supports check ensures older browsers get static values while modern browsers get the full light/dark capability. As browser support continues to widen, you can eventually remove the fallback block entirely.