EmpireUI
Get Pro
← Blog7 min read#css#dark-mode#color-scheme

CSS light-dark() Function: System Preference Without Media Query

The CSS light-dark() function lets you respond to system color scheme preferences in a single property value — no media query wrapper required. Here's how it works.

Dark and light split screen showing code editor with color theme switching

What Is the CSS light-dark() Function?

Honestly, the day light-dark() landed in browsers was the day a lot of boilerplate theming code became unnecessary. It's a CSS color function that takes two values — one for light mode, one for dark — and returns whichever one matches the user's system preference. That's it. No @media (prefers-color-scheme: dark) wrapper.

The function signature is dead simple: light-dark(light-value, dark-value). You can use it anywhere you'd write a color. Background, border, box-shadow, text fill — anything that accepts a color value. It resolves at computed style time based on the color-scheme property in scope.

Browser support landed progressively through 2024. As of late 2025, Chrome 123+, Firefox 120+, and Safari 17.5+ all support it with no prefix. If you're building for those targets, you can ship it today. For older fallbacks, CSS custom properties with a media query override still work fine as a progressive enhancement layer.

The color-scheme Property: The Hidden Prerequisite

Here's the thing: light-dark() doesn't work in a vacuum. It depends on the color-scheme CSS property being set somewhere in the cascade. Without it, the function has no context to resolve against and browsers treat both values as invalid.

The most common setup is declaring color-scheme: light dark on :root. That single declaration tells the browser your document supports both schemes. From that point forward, light-dark() everywhere in your stylesheet has the context it needs — either from the user's OS preference or from an explicit override you set on a container.

You can also scope color-scheme to individual elements. Set color-scheme: dark on a <div> and every light-dark() call inside it resolves to the dark value, regardless of what the OS says. That's genuinely useful for things like dark sidebars in otherwise light UIs.

:root {
  color-scheme: light dark;
}

.sidebar {
  /* Forces dark resolution for this subtree */
  color-scheme: dark;
  background: light-dark(#f5f5f5, #1a1a2e);
  color: light-dark(#111827, #e2e8f0);
}

Replacing @media Blocks with Inline light-dark() Values

Before light-dark(), a typical theming pattern looked like this: define your light colors, then override them inside @media (prefers-color-scheme: dark). That's two blocks of CSS for every property you want to theme. It works, but it scatters related values across the file.

With light-dark(), the light and dark values live together on the same line. Reading the property declaration tells you the full story — what it looks like in both contexts without hunting for a media query block somewhere below. That's a real readability improvement on anything that isn't Tailwind.

/* Before */
.card {
  background: #ffffff;
  border: 1px solid #e5e7eb;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

@media (prefers-color-scheme: dark) {
  .card {
    background: #1f2937;
    border: 1px solid #374151;
    box-shadow: 0 2px 8px rgba(0,0,0,0.4);
  }
}

/* After */
.card {
  background: light-dark(#ffffff, #1f2937);
  border: 1px solid light-dark(#e5e7eb, #374151);
  box-shadow: 0 2px 8px light-dark(rgba(0,0,0,0.08), rgba(0,0,0,0.4));
}

The after version isn't shorter per se, but it's denser and more scannable. You see the relationship between the light and dark values immediately.

Using light-dark() with CSS Custom Properties in React

CSS custom properties and light-dark() pair extremely well. Define your semantic token layer once using light-dark(), then use those tokens throughout your component styles. This is the pattern that actually scales.

In a React app, you'd typically set color-scheme on the <html> element — either statically or by toggling it programmatically when a user flips a theme switch. If you're doing a theme toggle in React, the simplest approach is setting document.documentElement.style.colorScheme = 'dark' (or 'light') instead of adding a class. The light-dark() calls in your CSS respond automatically.

// ThemeProvider.tsx
import { useEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';

type Scheme = 'light' | 'dark' | 'system';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [scheme, setScheme] = useLocalStorage<Scheme>('color-scheme', 'system');

  useEffect(() => {
    const root = document.documentElement;
    if (scheme === 'system') {
      root.style.removeProperty('color-scheme');
    } else {
      root.style.colorScheme = scheme;
    }
  }, [scheme]);

  return (
    <SchemeContext.Provider value={{ scheme, setScheme }}>
      {children}
    </SchemeContext.Provider>
  );
}

Notice there's no class toggling here, no dark: prefix juggling, no conditional style objects. The entire theme switch is one property assignment. Everything using light-dark() in your CSS just works.

CSS Custom Property Tokens with light-dark()

The real power shows up when you combine light-dark() with a token system. Define your semantic colors once at :root, then every component that uses var(--color-surface) or var(--color-text-primary) automatically gets the right value for the active scheme.

:root {
  color-scheme: light dark;

  --color-bg:            light-dark(#ffffff, #0f172a);
  --color-surface:       light-dark(#f8fafc, #1e293b);
  --color-surface-hover: light-dark(#f1f5f9, #273549);
  --color-border:        light-dark(#e2e8f0, #334155);
  --color-text-primary:  light-dark(#0f172a, #f8fafc);
  --color-text-muted:    light-dark(#64748b, #94a3b8);
  --color-accent:        light-dark(#6366f1, #818cf8);
  --shadow-card:         light-dark(
    0 1px 3px rgba(0,0,0,0.1),
    0 1px 3px rgba(0,0,0,0.5)
  );
}

.button-primary {
  background: var(--color-accent);
  color: #fff;
  padding: 10px 20px;
  gap: 8px;
  border-radius: 6px;
}

This pattern is essentially what design systems do with JSON design tokens, but without a build step. No Theo, no Style Dictionary, no transformer pipeline. Just CSS doing CSS things.

Compare this to Tailwind vs CSS Modules — if you're on Tailwind v4.0.2+ (which uses CSS variables under the hood), you can actually mix both worlds. Define your light-dark() tokens in a CSS layer and reference them from Tailwind's @theme block.

Glassmorphism and Semi-transparent Colors with light-dark()

Semi-transparent colors are where light-dark() gets interesting. You can't just invert a hex value for glassmorphism effects — the blur overlay that looks great at rgba(255,255,255,0.15) in light mode needs to be something like rgba(15,23,42,0.6) in dark mode. The values are completely different in character, not just brightness.

light-dark() handles this perfectly because you're passing two completely independent color values. There's no interpolation, no automatic inversion. You specify exactly what you want for each scheme. If you're building effects similar to what's described in what is glassmorphism, this is the cleanest way to handle the dual-mode version of a frosted-glass panel.

.glass-panel {
  background: light-dark(
    rgba(255, 255, 255, 0.15),
    rgba(15, 23, 42, 0.6)
  );
  backdrop-filter: blur(12px) saturate(180%);
  border: 1px solid light-dark(
    rgba(255, 255, 255, 0.3),
    rgba(255, 255, 255, 0.08)
  );
  box-shadow:
    0 4px 24px light-dark(
      rgba(0, 0, 0, 0.06),
      rgba(0, 0, 0, 0.4)
    );
}

Can you see how much cleaner this is than the media query equivalent? All the related values for a single component live together. That's not a minor aesthetic preference — it genuinely reduces the chance of forgetting to update the dark variant when you tweak the light one.

Caveats, Browser Quirks, and When Not to Use It

A few things will bite you. First, light-dark() only works inside a CSS context where color-scheme is inherited. If you're injecting styles into a Shadow DOM with no explicit color-scheme on the host, the function won't resolve correctly. You'll need to explicitly set color-scheme on the shadow root's :host selector.

Second, it's colors only. You can't use light-dark() to switch between two non-color values — like different font sizes or spacing values per scheme. For that, you still need custom properties toggled by a class or data-theme attribute, or a media query. The function has a narrow scope by design.

Third, JavaScript-driven theme switching needs care. When you set colorScheme programmatically, there can be a brief flash on initial load if your JS runs after paint. The fix is the same as always: set the value in a <script> tag before the closing </head>, reading from localStorage. This isn't a light-dark() problem specifically, but it's the same problem you'd hit with any CSS-variable theming system. For CSS Houdini fans, there's a related timing consideration worth reading about in CSS Houdini paint worklets as well.

When not to use it: if your app needs more than two modes (e.g., a sepia reading mode or a high-contrast accessibility mode), light-dark() isn't enough on its own. You'd need a data-attribute or class-based token system with light-dark() only handling the base two states.

Practical Migration Path for Existing Codebases

You don't have to rewrite your entire theme to start using light-dark(). The practical migration path is additive — leave your existing media queries in place and start writing new components or new properties using light-dark() where browser support fits your target audience.

For existing components, the migration is mostly mechanical: find every @media (prefers-color-scheme: dark) block, identify the properties it overrides, then collapse them into light-dark() calls on the base rule. A regex find-and-replace won't do it cleanly because of the specificity context, but a few minutes of manual work per component gets it done.

One migration tip: make sure color-scheme: light dark is on :root from day one of the migration. Without it, your new light-dark() calls look broken and the old media queries are still driving behavior. Adding that one line immediately activates all the light-dark() values across the entire document. Start there, then migrate property by property at your own pace.

The end state you're aiming for is a stylesheet with no @media (prefers-color-scheme) blocks at all — just a token layer at :root using light-dark() and components that consume those tokens via custom properties. It's achievable in most codebases without a big-bang rewrite.

FAQ

Does light-dark() work without setting color-scheme?

No. The light-dark() function depends on the color-scheme property being declared in the cascade. Without color-scheme: light dark (or just light or just dark) somewhere in scope, browsers have no context to resolve the function and it produces an invalid value. Always put color-scheme: light dark on :root as the first step.

Can I use light-dark() to switch non-color values like font sizes or spacing?

No — light-dark() is color-only. The spec restricts it to color values. For non-color adaptive values, you still need CSS custom properties toggled by a class, data attribute, or media query. That said, you can use light-dark() for a color token and a separate mechanism for non-color tokens in the same system without conflict.

How do I override light-dark() to force dark mode programmatically in JavaScript?

Set document.documentElement.style.colorScheme = 'dark' (or 'light'). That overrides the inherited value for the entire document, and all light-dark() calls resolve against the forced scheme. To return to system preference, remove the inline style: document.documentElement.style.removeProperty('color-scheme').

Does light-dark() replace CSS variables for theming?

Not entirely — they complement each other. The typical pattern is to use light-dark() inside CSS custom property definitions at :root to create semantic tokens, then consume those tokens with var() throughout your component styles. This gives you the clean co-location of light-dark() plus the indirection of custom properties.

What happens in browsers that don't support light-dark()?

The property declaration containing light-dark() is treated as invalid and skipped. Browsers fall back to whatever was previously computed for that property. The standard progressive enhancement approach: declare a safe fallback value before the light-dark() declaration, so older browsers use the fallback and supporting browsers override it.

Can light-dark() be used inside Tailwind CSS v4?

Yes. Tailwind v4.0.2 moved to a CSS-first configuration model where custom properties are defined in @theme blocks. You can define light-dark() tokens in a CSS layer and reference them from @theme, or use them directly in component utility classes. The color-scheme inheritance works the same way regardless of whether Tailwind or custom CSS is driving the utility classes.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Container Style Queries: CSS Theming Without JavaScriptNative CSS Nesting: Full Guide with Real Component ExamplesDark Mode in a Design System: Semantic Tokens That WorkTailwind + CSS Variables: Dynamic Theming Without JavaScript