CSS light-dark() Function: 3 Dark Mode Hacks It Replaces

2026-06-08 · Nico Brandt

Open any codebase with dark mode and you’ll find the same three messes. A :root block that declares every color twice. A @media (prefers-color-scheme: dark) block sitting at the bottom of every component file like a barnacle. And a JavaScript theme toggle that fights both of them.

The CSS light-dark() function takes two colors and returns the right one based on the user’s color scheme. That’s the entire feature. The interesting part isn’t what it adds — it’s what it lets you delete.

Three before/after refactors below. The :root variable cleanup, the media query block deletion, and the theme toggle that finally stops fighting itself. Working code, copy-paste ready. The question isn’t whether to use it — it’s which mess to kill first, and what catches you when you do.

What light-dark() Actually Does (and the One Line You Can’t Skip)

The whole function fits on one line:

color: light-dark(#1a1a1a, #fafafa);

First argument wins in light mode. Second wins in dark mode. The browser picks at resolution time, so the same declaration responds to system preference, user toggle, and forced color scheme without you writing any conditional logic.

The catch — and every tutorial buries this — is that light-dark() silently fails unless the element has an explicit color-scheme. No warning, no devtools highlight, no console error. It just always returns the first value. Put this at the top of your stylesheet and forget about it:

:root {
  color-scheme: light dark;
}

That’s the contract. color-scheme tells the browser the element supports both modes; light-dark() reads that signal and chooses. Forget the contract and your “broken” dark mode is one line away from working.

Browser support in 2026 is Baseline Widely Available — Chrome 123+, Firefox 120+, Safari 17.5+. No polyfill. It works anywhere a <color> is valid: background, border-color, box-shadow, gradients, outline-color. It shares that space with the CSS color-mix function — light-dark() picks by scheme, color-mix blends by ratio. The image form (light-dark(url(a.svg), url(b.svg))) ships too, for swapping logos and hero art.

That’s the function. Here’s what it kills.

Hack #1 It Replaces: The Duplicated :root Variable Block

Every design system I’ve inherited has this pattern. The light theme lives in :root. The dark theme lives in [data-theme="dark"] or a @media (prefers-color-scheme: dark) block. Twenty tokens become forty, and every new color has to be added in two places — which means it gets added in one place and forgotten in the other.

Before — a realistic design token block:

:root {
  --color-bg: #fafafa;
  --color-surface: #ffffff;
  --color-text: #1a1a1a;
  --color-text-muted: #71717a;
  --color-border: #e5e5e5;
  --color-accent: #4ade80;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0b0b0d;
    --color-surface: #16161a;
    --color-text: #f4f4f5;
    --color-text-muted: #a1a1aa;
    --color-border: #2a2a2e;
    --color-accent: #22c55e;
  }
}

Eighteen lines of which roughly nine are doing real work. The other nine are restating the schema with different values.

After — same tokens, half the file:

:root {
  color-scheme: light dark;
  --color-bg: light-dark(#fafafa, #0b0b0d);
  --color-surface: light-dark(#ffffff, #16161a);
  --color-text: light-dark(#1a1a1a, #f4f4f5);
  --color-text-muted: light-dark(#71717a, #a1a1aa);
  --color-border: light-dark(#e5e5e5, #2a2a2e);
  --color-accent: light-dark(#4ade80, #22c55e);
}

The entire @media block at the bottom — gone. Any [data-theme="dark"] overrides — gone. New colors get added once, and both themes update in the same line.

The reason this works: CSS custom properties resolve at usage time, not at declaration time. So when a component reads var(--color-bg), the browser asks the cascade for the current value, sees light-dark(#fafafa, #0b0b0d), and resolves it against the current color-scheme. Flip the scheme and every usage updates without touching the component.

Variables cleaned up. But your component CSS is still doing dark mode the old way.

Hack #2 It Replaces: The @media (prefers-color-scheme) Block

The second classic pattern lives one floor down. Every component file has its base styles, then a @media (prefers-color-scheme: dark) block at the bottom overriding half of them. You read the top of the file, you scroll to the bottom, and you cross-reference. That’s how dark mode debugging used to work.

Before — a card component shipping two color sets:

.card {
  padding: 1.25rem;
  border-radius: 0.5rem;
  background: #ffffff;
  border: 1px solid #e5e5e5;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
  color: #1a1a1a;
}

@media (prefers-color-scheme: dark) {
  .card {
    background: #16161a;
    border-color: #2a2a2e;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
    color: #f4f4f5;
  }
}

Fourteen extra lines doing nothing but flipping colors. The structural CSS — padding, border-radius — lives in one place. The color CSS lives in two.

After — every theme decision sits next to the property it modifies:

.card {
  padding: 1.25rem;
  border-radius: 0.5rem;
  background: light-dark(#ffffff, #16161a);
  border: 1px solid light-dark(#e5e5e5, #2a2a2e);
  box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.4));
  color: light-dark(#1a1a1a, #f4f4f5);
}

light-dark() works inside rgba() and inside box-shadow’s color slot — anywhere a color is valid. You read the component top to bottom and see both themes at once.

Honest caveat: this only replaces color-based @media (prefers-color-scheme) blocks. If you’re switching layouts, font sizes, hiding elements, or swapping non-color values based on theme, the media query stays. light-dark() is a color function, not a theme function. Don’t try to make it the latter.

Two messes down. There’s a third one most teams don’t notice until they ship a manual toggle — and that’s where the system usually splinters.

Hack #3 It Replaces: The Theme Toggle JavaScript Mess

Here’s the setup. Users want a light/dark switch that overrides the system preference. The classic implementation toggles a data-attribute on <html> and adds another CSS branch to handle it. Now you have three sources of truth: the @media query, the [data-theme] selectors, and the JavaScript. They drift apart the moment someone forgets to update one of them.

color-scheme flips that on its head. The toggle just sets color-scheme on the root element, and every light-dark() call on the page reacts automatically. No [data-theme] selectors. No second CSS branch. No drift. For non-color properties that still need conditional logic, the CSS if() function covers that territory — but for theming colors, light-dark() is the right tool.

The script, in full:

const root = document.documentElement;

function setScheme(scheme) {
  root.style.colorScheme = scheme;
  localStorage.setItem('color-scheme', scheme);
}

function toggle() {
  setScheme(root.style.colorScheme === 'dark' ? 'light' : 'dark');
}

const saved = localStorage.getItem('color-scheme');
if (saved) setScheme(saved);

That’s it. The CSS doesn’t change. The :root block from Hack #1 still uses light-dark(), and the moment color-scheme becomes 'dark', every color resolves to its second argument.

The bonus most tutorials skip: this also fixes scrollbars, form controls, default focus rings, and any UA-rendered widgets. Those listen to color-scheme, not to your [data-theme] attribute. Toggle the attribute and the scrollbars stay light. Toggle color-scheme and they flip with everything else.

Leave color-scheme: light dark on :root as your default — that respects system preference. Only override it (with 'light' or 'dark' specifically) when the user makes an explicit choice. The default value means “the user hasn’t decided yet, follow the OS.”

Three patterns retired. A couple of edge cases worth knowing before you push this to main.

Two Edge Cases Worth Knowing Before You Ship

The silent-fail trap. Forgetting color-scheme: light dark means light-dark() always returns the first value. No warning, no devtools highlight, no error in the console. If a teammate reports that dark mode “isn’t working,” this is the first thing to check — even before they finish describing the bug.

Progressive enhancement. For the small population on browsers without support (mostly old mobile), the declaration is dropped and the previous value wins. Stack a fallback above it:

.card {
  background: #ffffff;
  background: light-dark(#ffffff, #16161a);
}

Or gate a whole block with @supports:

@supports (color: light-dark(#000, #fff)) {
  /* light-dark() rules here */
}

If you want to go further with typed CSS properties after this, the CSS @property tutorial covers animatable custom properties — the natural next step once you’re comfortable with modern CSS property capabilities.

Worth noting: color-scheme itself already degrades gracefully — unsupported browsers ignore it and stay light. So the fallback is mostly for the rare deeply-customized component.

One last thing. light-dark() composes with CSS custom properties; it doesn’t replace them. Your design tokens still belong in :root. The function is the value you assign, not a replacement for the variable system. If you’ve spent two years on a token architecture like a layered custom-properties setup, keep it. This goes inside.

The Bottom Line

The question at the top was which dark mode mess light-dark() kills first. The answer is all three. The duplicated :root block collapses to half the lines. The @media (prefers-color-scheme) overrides at the bottom of every component file delete cleanly. The theme toggle stops fighting two CSS branches and just flips color-scheme.

If you’re starting a new theme today, write it with light-dark() from line one. If you have an existing theme, refactor the :root block first — biggest win, smallest blast radius. The component files can follow at your own pace.

One rule to remember: set color-scheme: light dark on :root, or none of this works. Everything else is variations on that single line.