Every codebase with dark mode support has this structure: the component styles, then a @media (prefers-color-scheme: dark) block that restates the same selectors with different colors. The media query block might be at the bottom of the file, in a separate dark-mode stylesheet, or scattered next to each component — but it's always a duplication. You're maintaining two values per color, physically separated, for every element that changes appearance between modes.
light-dark() is the native alternative. It takes two values — one for light mode, one for dark — and picks the right one based on the color-scheme in scope. Same property, same rule, both values present, no media query needed.
The color-scheme prerequisite
light-dark() resolves against the used color-scheme of the element. Miss this and you get the quietest bug in the feature: without color-scheme: light dark in scope, the element only supports light, so light-dark() returns its first argument and your dark value never applies. Nothing errors. Nothing is invalid. Dark mode just never happens. The minimum setup is a single declaration on :root:
:root {
color-scheme: light dark;
}
This does two things. It makes light-dark() work for every descendant element. And it tells the browser to style its own UI — scrollbars, form controls, the default link color — to match the user's system preference. The order light dark means "prefer light, support dark." Flip it to dark light for "prefer dark, support light."
That one line on :root is the only required change to your base styles.
Replacing a media query block
Here is a card component written the traditional way — base styles, then a dark-mode override:
.card {
background: #ffffff;
color: #111827;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
.card {
background: #1f2937;
color: #f9fafb;
border-color: #374151;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
}
Eight declarations to express four color pairs. The dark-mode values are separated from their light counterparts by the media query boundary — you cannot see them together without scrolling.
With light-dark():
.card {
background: light-dark(#ffffff, #1f2937);
color: light-dark(#111827, #f9fafb);
border: 1px solid light-dark(#e5e7eb, #374151);
box-shadow: 0 1px 3px light-dark(rgba(0,0,0,0.1), rgba(0,0,0,0.5));
}
Four declarations, both values visible on the same line. The pairing is explicit and local. When a designer asks what the dark-mode border color is, you read it directly from the same rule as the light-mode border color.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Using it with custom properties
light-dark() works inside custom property values. That combination is where the real leverage is — centralize your color pairs as tokens on :root, and every component references them semantically:
:root {
color-scheme: light dark;
--bg-surface: light-dark(#ffffff, #1f2937);
--bg-subtle: light-dark(#f9fafb, #111827);
--text-primary: light-dark(#111827, #f9fafb);
--text-muted: light-dark(#6b7280, #9ca3af);
--border-subtle: light-dark(#e5e7eb, #374151);
}
.card {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
}
.sidebar {
background: var(--bg-subtle);
color: var(--text-muted);
}
Adding a new component means using existing tokens — not writing another media query block. Changing a color pair means updating one line on :root — not finding and updating two separate declarations.
Toggling themes programmatically
Because light-dark() resolves against the color-scheme property — not only the @media context — you can switch themes by changing color-scheme on the root element directly from JavaScript:
function setTheme(theme) {
document.documentElement.style.colorScheme = theme; // 'light', 'dark', or ''
}
// Toggle button
button.addEventListener('click', () => {
const current = getComputedStyle(document.documentElement).colorScheme;
setTheme(current === 'dark' ? 'light' : 'dark');
});
Setting colorScheme to '' removes the inline override and falls back to the system preference. No class toggling, no custom property overwriting, no JavaScript that knows about individual color values. The switch happens in one property assignment; every light-dark() value in the page updates automatically.
This is cleaner than the common [data-theme="dark"] attribute pattern, which requires writing [data-theme="dark"] .component { ... } selector blocks that bypass the browser's native mechanism and add specificity overhead.
What the function accepts
light-dark() is not limited to hex colors or rgba(). It accepts any CSS color value in both arguments:
.element {
/* oklch colors */
color: light-dark(oklch(30% 0.15 260), oklch(90% 0.12 260));
/* named colors */
background: light-dark(white, #1a1a2e);
/* CSS custom properties */
color: light-dark(var(--brand-navy), var(--brand-sky));
/* currentColor */
border-color: light-dark(currentColor, oklch(70% 0 0));
}
The two arguments can be different color spaces. The browser resolves them independently based on mode. There is no requirement that they share a format.
Browser support
light-dark() is Baseline 2024: Chrome 123 (March 2024), Firefox 120 (November 2023), Safari 17.5 (May 2024) — newly available since Safari rounded out the set in May 2024.
Older browsers ignore a rule containing light-dark() entirely. A safe progressive enhancement:
.card {
background: #ffffff; /* fallback: always light */
background: light-dark(#ffffff, #1f2937); /* enhanced: picks by mode */
}
@media (prefers-color-scheme: dark) {
.card {
background: #1f2937; /* fallback for old browsers in dark mode */
}
}
If your browser targets are Chrome 123+, Firefox 120+, and Safari 17.5+, the fallbacks can be dropped entirely.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The takeaway
Search your stylesheet for @media (prefers-color-scheme: dark). Each block is a duplication of selectors and properties you already wrote — the only thing that changed is the color values. Convert each light/dark pair to a single light-dark() call on the same line. Then move the light-dark() calls to custom properties on :root so components reference tokens, not raw colors. The result is a stylesheet where every color pair lives in exactly one place, the pairing is visible without scrolling, and adding a new component means choosing tokens — not writing another media query.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)