Skip to main content
Martin.

The CSS light-dark() Function: How I Stopped Writing Two Colour Palettes

Every dark mode implementation I've seen in the wild follows the same pattern. You define your light colours as custom properties, then override them inside a class or data attribute selector:

:root {
  --color-bg: #f5f0eb;
  --color-text: #2a2420;
}

[data-theme="dark"] {
  --color-bg: #1a1816;
  --color-text: #f0ebe5;
}

It works. I've shipped it. You've probably shipped it. But it has a problem that bugged me for a while: you're defining the same token in two completely separate places. If you add a new colour, you have to remember to define it in both blocks. If you rename something, two places. If you forget one, you get a broken theme and probably don't notice until someone sends you a screenshot.

There's a better way now, and it's just CSS. No build tool, no JavaScript, no preprocessor.

Enter light-dark()

The light-dark() function lets you define both values in a single declaration:

--color-bg: light-dark(#f5f0eb, #1a1816);

The first argument is the light value. The second is the dark value. The browser picks the right one based on the element's computed color-scheme.

That's it. One line, both themes.

How It Knows Which to Use

light-dark() doesn't read a class or a data attribute. It reads the color-scheme property. You need to tell the browser which scheme is active:

:root {
  color-scheme: dark;
}

:root[data-theme="light"] {
  color-scheme: light;
}

When color-scheme is dark, every light-dark() call resolves to its second argument. When it's light, the first. The browser handles it natively — no selector specificity games, no duplication.

This is what I use on this site. The default is dark (because it's 2026). When the user toggles to light mode, a data-theme="light" attribute gets set on <html>, which flips the color-scheme, which flips every colour in the system. One switch, everything updates.

A Real Example

Here's a simplified version of the design tokens on this site:

@theme {
  /* Backgrounds */
  --color-bg: light-dark(oklch(0.97 0.008 75), oklch(0.14 0.012 60));
  --color-surface: light-dark(oklch(0.94 0.01 75), oklch(0.19 0.01 60));
  --color-surface-hover: light-dark(oklch(0.91 0.012 75), oklch(0.23 0.01 60));

  /* Borders */
  --color-border: light-dark(oklch(0.84 0.012 75), oklch(0.3 0.008 60));
  --color-border-subtle: light-dark(oklch(0.88 0.008 75), oklch(0.25 0.006 60));

  /* Text */
  --color-text: light-dark(oklch(0.22 0.015 60), oklch(0.93 0.008 75));
  --color-text-secondary: light-dark(oklch(0.42 0.02 60), oklch(0.7 0.012 75));
  --color-text-muted: light-dark(oklch(0.55 0.015 60), oklch(0.58 0.012 75));

  /* Accent (terracotta / copper) */
  --color-accent: light-dark(oklch(0.55 0.16 38), oklch(0.7 0.14 45));
  --color-accent-hover: light-dark(oklch(0.5 0.17 35), oklch(0.75 0.15 42));
}

Every token, both themes, one block. When I need a new colour I add one line, and it works in both modes immediately.

The color-scheme toggle is just two rules:

:root {
  color-scheme: dark;
}

:root[data-theme="light"] {
  color-scheme: light;
}

There's no second block of overrides. No specificity to manage. If I delete a token, it's gone from both themes. If I tweak a dark mode value, I can see its light counterpart right there on the same line.

What About the Server?

If you're doing SSR (and you probably should be), you need the data-theme attribute to be set before the page renders. Otherwise you'll get a flash of the wrong theme.

I wrote a separate post about that — the short version is: store the preference in a cookie, read it in your server loader, and set the attribute on <html> before the first paint. The CSS side doesn't care how the attribute gets there; it just needs color-scheme to be correct.

Why OKLCH?

You might have noticed I'm using oklch() for all the colour values. That's a separate topic, but the short answer: OKLCH is perceptually uniform, which means a lightness of 0.5 actually looks like it's halfway between black and white. It makes it much easier to build a palette where the light and dark variants feel balanced.

It also means you can adjust lightness and chroma independently. If I want the dark mode text to be slightly warmer, I bump the hue by a few degrees without affecting how bright it appears. Try doing that with hex values.

Browser Support

light-dark() is supported in all modern browsers — Chrome 123+, Firefox 120+, Safari 17.5+. As of early 2026, that covers well over 95% of users. If you need to support older browsers, you can use a fallback:

--color-bg: oklch(0.14 0.012 60); /* fallback: dark mode default */
--color-bg: light-dark(oklch(0.97 0.008 75), oklch(0.14 0.012 60));

Browsers that don't understand light-dark() will ignore the second declaration and use the fallback. Browsers that do will use the second one. Progressive enhancement at its simplest.

The Whole Picture

Here's how the pieces fit together:

  1. color-scheme tells the browser which theme is active
  2. light-dark() resolves colour values based on that scheme
  3. A data-theme attribute on <html> lets you toggle color-scheme via CSS
  4. Your server sets that attribute before the first paint (via a cookie or similar)
  5. A toggle button in the UI updates the cookie and flips the attribute

No JavaScript colour logic. No duplicate token blocks. No flash of wrong theme. Just CSS doing what CSS should do.

That's it. Dark mode, done properly. No flash, no flicker.

If you run into any issues, you know where to find me.