Home / Articles / Color & Theming /
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.