Skip to main content

Harshal V. LADHE

One-Line Theme-Aware Colors with light-dark()

A single declaration per color, resolved automatically for both light and dark mode.
Published at:
Last updated:
Estimated reading time:3 min read

Introduction

Theming a color for both light and dark mode has usually meant one of two things: a @media (prefers-color-scheme: dark) block redeclaring every color property, or a full set of duplicate custom properties (--bg-light / --bg-dark) swapped via a class toggle. Both work. Both also mean every themed color exists twice in your stylesheet.

light-dark() collapses that down to one declaration: give it a light value and a dark value, and it resolves to whichever one is active — reactively, with zero duplication.

The Core Function

:root {
  color-scheme: light dark; /* opt in: let this element resolve BOTH schemes */
}

.card {
  background: light-dark(white, #1a1a1a);
  color: light-dark(#111, #eee);
  border: 1px solid light-dark(#ddd, #333);
}

Two things have to be true for light-dark() to resolve at all: the color-scheme property has to include both light and dark somewhere in the cascade reaching that element (declaring intent to support both), and the actual active scheme is then decided by whatever normally decides color-scheme — the user's OS/browser preference by default, or an explicit color-scheme: light / color-scheme: dark override anywhere between that element and the root.

Try It Live

Two panels, same three light-dark() declarations, two different ways of driving color-scheme. Automatic is just color-scheme: light dark and nothing else — no button, no JS — so it's genuinely following your browser/OS dark-mode preference right now; switch your system theme and it re-resolves entirely on its own. Manual Override starts forced light regardless of your system setting, and only the button ever changes its color-scheme — the same mechanism a real "force dark mode for this widget" toggle would use.

light-dark()

Toggle color-scheme on a panel and watch every light-dark() value re-resolve instantly — background, text, and border, from one declaration each.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>light-dark() Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>light-dark()</h1>
        <p>One declaration per property — no duplicate variable sets, no media query.</p>
      </header>

      <section class="demo-block">
        <p class="demo-label">Automatic — follows your system preference, no button, no JS</p>
        <div class="panel" id="system-panel">
          <h3>Automatic</h3>
          <p>Background, text, and border all come from a single declaration each:</p>
          <code>color-scheme: light dark;</code>
        </div>
      </section>

      <section class="demo-block">
        <p class="demo-label">Manual override — the same mechanism, applied explicitly</p>
        <button id="toggle-btn" class="toggle-btn" type="button">Force dark scheme</button>
        <div class="panel" id="override-panel">
          <h3>Manual Override</h3>
          <p>Ignores your system preference — only this button changes it:</p>
          <code id="override-code">color-scheme: light;</code>
        </div>
      </section>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Read-only
Ln , Col HTML1.3 KBUTF-8

Starting sandbox…

No console output yet.

Per-Element Overrides

color-scheme isn't limited to :root — setting it on any element scopes the resolution of every light-dark() value inside it, which is exactly what the Manual Override panel above does:

:root {
  color-scheme: light dark; /* page follows the user's OS preference by default */
}

.always-dark-sidebar {
  color-scheme: dark; /* this subtree always resolves light-dark() as dark, regardless of OS setting */
}

This is exactly how a "force dark mode for this one widget" or a manual light/dark toggle button works — you're not switching custom properties, you're changing which branch of every light-dark() call in that subtree is active.

Comparison with the Old Approaches

/* Before: @media, one full redeclaration per themed property */
.card {
  background: white;
  color: #111;
}

@media (prefers-color-scheme: dark) {
  .card {
    background: #1a1a1a;
    color: #eee;
  }
}

/* Before: duplicate custom properties + a class toggle */
:root {
  --bg: white;
  --fg: #111;
}

.dark-mode {
  --bg: #1a1a1a;
  --fg: #eee;
}

.card {
  background: var(--bg);
  color: var(--fg);
}

/* Now: one line per property, no duplication, no toggle class needed */
.card {
  background: light-dark(white, #1a1a1a);
  color: light-dark(#111, #eee);
}

Where Should You Use This?

  • Design tokens/component libraries where every color needs a light and dark variant
  • Small widgets that should always respect the user's OS scheme without a manual toggle
  • Quickly retrofitting dark mode onto an existing light-only stylesheet, one property at a time
  • Any color declaration currently duplicated across a @media (prefers-color-scheme: dark) block

Production Considerations & Edge Cases

  • color-scheme: light dark (both) is required to actually use both branches — setting only color-scheme: light makes every light-dark() in that scope always resolve to its light value, regardless of OS preference. This is a common early mistake: forgetting dark in the declaration and wondering why dark mode never kicks in.
  • This doesn't replace @media (prefers-color-scheme: dark) entirelylight-dark() only helps with color values. Structural changes (different layout, different images, different display values) still need the media query.
  • It also affects native UI elements. color-scheme doesn't just gate light-dark() — it also tells the browser to render scrollbars, form controls, and other native chrome in the matching scheme, which happens automatically as a side effect of setting it.
  • Browser support is solid in every modern evergreen browser (Chrome/Edge 123+, Firefox 120+, Safari 17.5+) as of this writing — check your target matrix, and keep a @supports not (background: light-dark(white, black)) fallback if older browsers matter.

Key Takeaway

light-dark() turns "every themed color needs a light value and a dark value maintained in two places" into "every themed color needs a light value and a dark value in the same declaration." Reach for it any time dark mode support means duplicating color rules across a media query.

Categories:CSS
Tags:

Changelog

  • Initial publication.
This snippet is licensed under CC BY 4.0 by the author.

Share this snippet