Dark Mode CSS Variables: Theme Systems with light-dark()

How to Build a No-Flash CSS Dark Mode Toggle

  1. Declare color-scheme: light dark on :root and add a <meta name="color-scheme"> tag in <head>.
  2. Define design tokens as CSS custom properties using light-dark() for each color value.
  3. Wrap light-dark() tokens in an @supports block and provide a prefers-color-scheme media-query fallback outside it.
  4. Add a blocking inline script in <head> before stylesheets to read localStorage and set data-theme and style.colorScheme.
  5. Create data-theme CSS overrides that force color-scheme to a single keyword when the user picks a side.
  6. Build a toggle button that cycles through system → light → dark states and persists the choice to localStorage.
  7. Listen for matchMedia changes so the page reacts live to OS preference shifts when no override is stored.
  8. Test both themes in DevTools emulation, verify WCAG AA contrast ratios, and audit for hard-coded color values.

Dark mode has become a baseline expectation, yet building it with plain CSS and JavaScript still catches experienced developers off guard. This tutorial builds a complete, no-flash, plain-CSS-and-JS dark mode toggle that respects system preferences, persists the user’s choice, and uses the CSS light-dark() function with progressive enhancement for older browsers.

Table of Contents

Why Dark Mode Still Trips Developers Up

Dark mode has become a baseline expectation, yet building it with plain CSS and JavaScript still catches experienced developers off guard. The core problems remain stubborn: the flash of wrong theme (FOUC) when a page loads before JavaScript can apply a stored preference, media query blocks scattered and duplicated throughout stylesheets, and the persistence logic needed to remember what a user actually chose. Building a no-flash dark mode CSS variables system without a framework has historically meant writing every color declaration twice inside prefers-color-scheme blocks, or leaning on JavaScript to do work that CSS should handle natively.

That changes with the CSS light-dark() function, which reached Baseline 2024 status (Baseline Newly Available, meaning supported in all major engines but not yet in the 30-month rolling-availability window), shipping in Chrome 123+, Firefox 120+, Safari 17.5+, and Edge 123+. Current support tables are on . This tutorial builds a complete, no-flash, plain-CSS-and-JS css dark mode toggle that respects system preferences and persists the user’s choice via localStorage. The deliverable is a drop-in token system with @supports-based fallback blocks for older browsers, an early-load script to prevent theme flash, and a toggle script with three-state cycling. Progressive enhancement ensures pre-2024 browsers still get a working dark mode.

How color-scheme and light-dark() Work Together

What color-scheme Does

You set the color-scheme CSS property on :root to signal the browser which color schemes the page supports. This is not merely a hint. It directly affects how the user agent renders built-in UI elements: form controls (inputs, selects, checkboxes), scrollbars, <hr> elements, and system colors like Canvas, CanvasText, and ButtonFace all shift appearance based on the declared color-scheme css value.

The property accepts several keyword values. light declares that only a light scheme is supported. dark declares only dark. light dark (the most common for theme-switching pages) tells the browser both are supported, and the browser will choose based on the user’s operating system preference. only light opts out of any forced darkening the browser might otherwise attempt.

Set this property on :root so the entire page’s UA-styled elements respond correctly when the active scheme changes, as MDN documentation confirms.


:root {
  
  color-scheme: light dark;
}

The light-dark() Function Explained

The light-dark() CSS function accepts exactly two arguments: light-dark(<light-value>, <dark-value>). When the computed color-scheme resolves to light, the function returns the first value. When it resolves to dark, it returns the second. light-dark() accepts <color> values only — this includes hex, hsl(), oklch(), named colors, and custom properties that resolve to colors. Non-color value types such as <shadow>, <length>, or <number> are not supported and will silently fail.

There is a critical dependency: color-scheme must include both light and dark keywords somewhere in the cascade for light-dark() to toggle correctly. If color-scheme is set to only light or simply light, the function silently resolves to the first value regardless of the user’s OS preference. This is a common source of bugs.

There is a critical dependency: color-scheme must include both light and dark keywords somewhere in the cascade for light-dark() to toggle correctly. If color-scheme is set to only light or simply light, the function silently resolves to the first value regardless of the user’s OS preference.

Compared to the traditional approach, light-dark() cuts the token block roughly in half. Previously, implementing dark theme css variables required writing a base set of custom properties and then overriding every one inside a @media (prefers-color-scheme: dark) block, effectively duplicating the entire token map.


:root {
  color-scheme: light dark;
}

body {
  
  background-color: light-dark(#ffffff, #121212);
  color: light-dark(#1a1a1a, #e0e0e0);
}

Setting Up the HTML Foundation

The <meta name="color-scheme"> Tag

Before any stylesheet link appears in <head>, place <meta name="color-scheme" content="light dark">. This matters because the browser reads meta tags before it begins parsing or fetching CSS. With this meta tag present, the browser applies UA color adjustments (dark canvas, light text on system-level elements) immediately during initial render, eliminating the white flash that occurs when a dark-mode user loads a page that briefly displays a white background before stylesheets arrive.

Minimal Page Markup

The page structure uses semantic HTML: a <header> containing navigation and the toggle button, a <main> for content, and a <footer>. The <html> element carries a data-theme attribute that JavaScript will set to override system preference when the user makes an explicit choice.


<!DOCTYPE html>
<html lang="en" data-theme="">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  
  <meta name="color-scheme" content="light dark">
  <title>Dark Mode Theme System</title>

  
  <script>
    (function () {
      var stored;
      try { stored = localStorage.getItem('theme'); } catch(e) { stored = null; }
      if (stored === 'light' || stored === 'dark') {
        document.documentElement.setAttribute('data-theme', stored);
        document.documentElement.style.colorScheme = stored;
      }
    })();
  </script>

  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <header>
    <nav>
      <a href="/">Site Name</a>
      <button
        id="theme-toggle"
        type="button"
        aria-label="Toggle dark mode"
      >
        <span class="toggle-icon" aria-hidden="true"></span>
      </button>
    </nav>
  </header>
  <main>
    <article class="card">
      <h2>Card Title</h2>
      <p>Sample content to demonstrate the theme system.</p>
      <button type="button" class="btn-primary">Action</button>
    </article>
  </main>
  <footer>
    <p>&copy; 2025 Site Name</p>
  </footer>
  <script src="theme-toggle.js"></script>
</body>
</html>

Building the CSS Token System

Defining Design Tokens with light-dark()

The core of this system is a custom-property token map declared on :root. Each token uses light-dark() to encode both theme values in a single declaration. Using oklch for color definitions provides perceptual uniformity, meaning that lightness values translate more predictably across light and dark palettes than hsl or hex. However, hsl and hex work equally well syntactically with light-dark().

This token map is the main deliverable of the dark mode css variables architecture and the foundation you will extend for your own design system.

Note: This example requires light-dark() support. See Code example 6 for the complete progressive-enhancement version with fallback for older browsers. Also note that light-dark() accepts <color> values only — shadow values must be handled separately via data-theme selectors or media queries.


:root {
  color-scheme: light dark;

  
  --color-surface-1: light-dark(#ffffff, #121212);
  --color-surface-2: light-dark(#f5f5f5, #1e1e1e);
  --color-surface-3: light-dark(#e8e8e8, #2a2a2a);

  
  --color-text-primary: light-dark(
    oklch(0.2 0 0),
    oklch(0.9 0 0)
  );
  --color-text-secondary: light-dark(
    oklch(0.4 0 0),
    oklch(0.7 0 0)
  );
  
  --color-text-muted: oklch(0.55 0 0);

  
  --color-border: light-dark(
    hsl(0 0% 85%),
    hsl(0 0% 25%)
  );

  
  --color-accent: light-dark(
    oklch(0.55 0.2 260),
    oklch(0.72 0.18 260)
  );
  --color-accent-text: light-dark(#ffffff, #121212);

  
  --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.12);
  --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.1);
}


:root[data-theme="dark"] {
  --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.4);
  --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.5);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.4);
    --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.5);
  }
}

Applying Tokens to Components

Components consume these tokens without any awareness of which theme is active. There are no media queries, no theme-conditional logic, and no data-theme selectors inside component CSS. All switching happens at the token layer.

Note: The transition on body will animate on initial page load if a stored theme is applied by script. See the initialization note in the Toggle Script section for how to suppress this.




.no-transition,
.no-transition * {
  transition: none !important;
}

body {
  background-color: var(--color-surface-1);
  color: var(--color-text-primary);
  font-family: system-ui, sans-serif;
  line-height: 1.6;
  transition: background-color 0.2s ease, color 0.2s ease;
}

.card {
  background-color: var(--color-surface-2);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: var(--shadow-elevation-1);
}

.card h2 {
  color: var(--color-text-primary);
  margin-top: 0;
}

.card p {
  color: var(--color-text-secondary);
}

.btn-primary {
  background-color: var(--color-accent);
  color: var(--color-accent-text);
  border: none;
  border-radius: 6px;
  padding: 0.6rem 1.2rem;
  cursor: pointer;
  font-weight: 600;
}

header {
  background-color: var(--color-surface-3);
  padding: 1rem 2rem;
  border-bottom: 1px solid var(--color-border);
}

header nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

footer {
  color: var(--color-text-muted);
  text-align: center;
  padding: 2rem;
}

Progressive Enhancement with @supports

Not every browser in the wild supports light-dark(). Pre-2024 versions of Chrome, Firefox, and Safari will ignore it entirely, which means the browser resolves custom properties to their guaranteed-invalid value (equivalent to unset, not an empty string), and the page falls back to inherited or initial styles. The solution is to wrap light-dark() tokens inside an @supports block and provide a fallback outside it that uses prefers-color-scheme media queries with the same custom properties.

This approach ensures older browsers receive a fully functional dark mode via the traditional media query pattern, while modern browsers benefit from the streamlined light-dark() css syntax.

Note: Fallback values must use color formats supported by the target legacy browsers — avoid oklch() and lch() in fallback blocks, as browsers old enough to lack light-dark() may also lack oklch() support.




:root {
  --color-surface-1: #ffffff;
  --color-surface-2: #f5f5f5;
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #555555;
  --color-text-muted: #8a8a8a;
  --color-border: hsl(0 0% 85%);
  --color-accent: #5b5bd6;
  --color-accent-text: #ffffff;
  --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.12);
  --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.1);
}


@supports not (color: light-dark(#000, #fff)) {
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --color-surface-1: #121212;
      --color-surface-2: #1e1e1e;
      --color-text-primary: #e0e0e0;
      --color-text-secondary: #aaaaaa;
      --color-text-muted: #8a8a8a;
      --color-border: hsl(0 0% 25%);
      --color-accent: #7c7ce8;
      --color-accent-text: #121212;
      --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.4);
      --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.5);
    }
  }

  
  :root[data-theme="dark"] {
    --color-surface-1: #121212;
    --color-surface-2: #1e1e1e;
    --color-text-primary: #e0e0e0;
    --color-text-secondary: #aaaaaa;
    --color-border: hsl(0 0% 25%);
    --color-accent: #7c7ce8;
    --color-accent-text: #121212;
  }
}


@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.4);
    --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.5);
  }
}


@supports (color: light-dark(#000, #fff)) {
  :root {
    color-scheme: light dark;

    --color-surface-1: light-dark(#ffffff, #121212);
    --color-surface-2: light-dark(#f5f5f5, #1e1e1e);
    --color-text-primary: light-dark(#1a1a1a, #e0e0e0);
    --color-text-secondary: light-dark(#555555, #aaaaaa);
    --color-text-muted: #8a8a8a;
    --color-border: light-dark(hsl(0 0% 85%), hsl(0 0% 25%));
    --color-accent: light-dark(
      oklch(0.55 0.2 260),
      oklch(0.72 0.18 260)
    );
    --color-accent-text: light-dark(#ffffff, #121212);

    
    --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.12);
    --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.1);
  }

  :root[data-theme="dark"] {
    --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.4);
    --shadow-elevation-2: 0 4px 12px rgba(0, 0, 0, 0.5);
  }
}

Eliminating Theme Flash with an Early-Load Script

Why Flash Happens

The flash of wrong theme occurs because the browser renders content using the default color-scheme (determined by the meta tag and OS preference) before any JavaScript can execute to apply a stored user preference. If a user previously chose light mode but their OS runs dark, the page briefly renders dark before JavaScript switches it, causing a visible flicker. This flash specifically affects users whose stored preference differs from their OS setting. Users with no stored preference experience no JS-driven flash, as the color-scheme: light dark meta tag handles their initial render.

The Inline <script> Strategy

A blocking inline <script> placed in <head> after the meta tag but before the stylesheet link runs synchronously during parsing. It reads from localStorage, applies the stored theme via the data-theme attribute on <html>, and overrides the color-scheme inline style. The <meta name="color-scheme"> tag handles the render phase before any script runs. The inline script’s style.colorScheme override then takes over for user-stored preferences. Both are necessary: the meta tag cannot be omitted, because it prevents a white flash during the brief window before the script executes. The script is roughly 10 lines and under 200 bytes, so its blocking cost is sub-millisecond on modern desktop hardware. On very low-end mobile devices, expect slightly longer, but still well under a frame budget.

Both are necessary: the meta tag cannot be omitted, because it prevents a white flash during the brief window before the script executes. The script is roughly 10 lines and under 200 bytes, so its blocking cost is sub-millisecond on modern desktop hardware.



(function () {
  var stored;
  try { stored = localStorage.getItem('theme'); } catch(e) { stored = null; }
  
  if (stored === 'light' || stored === 'dark') {
    document.documentElement.setAttribute('data-theme', stored);
    document.documentElement.style.colorScheme = stored;
  }
  
  
})();

Note: localStorage can throw a SecurityError in sandboxed iframes and browsers with storage disabled (e.g., certain Firefox privacy configurations, Safari ITP contexts). Always wrap localStorage access in a try/catch for production use.

Coordinating data-theme with CSS

When the user overrides their system preference, the data-theme attribute on <html> needs to force color-scheme to a single value so that light-dark() resolves correctly. Two concise rule blocks accomplish this.


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

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

Because color-scheme is now forced to a single keyword, every light-dark() call throughout the token system resolves to the appropriate value, regardless of what the OS preference might be.

Adding the Toggle with localStorage Persistence

Toggle Button UX

The toggle button updates aria-label dynamically on each state change to communicate its current action (e.g., “Currently light mode. Switch to dark mode”). Using aria-pressed for a three-state control requires "mixed" for the system state; alternatively, omit aria-pressed and rely solely on a descriptive aria-label. The button swaps between sun, moon, and computer icons. The icon represents the next state the user will activate, not the current theme — a common UX convention for toggle buttons.

Toggle Script

The toggle script cycles through three states: light, dark, and system. In the “system” state, it removes the manual override entirely, allowing prefers-color-scheme to govern again.

Place this script just before the closing </body> tag (save it as theme-toggle.js and include it with <script src="theme-toggle.js"></script>), or wrap the IIFE contents in a DOMContentLoaded listener if placing it in <head>.

The applyTheme call during initialization will trigger CSS transitions on page load. The code below suppresses this by temporarily adding a no-transition class and removing it after the browser has painted via a double requestAnimationFrame.



(function () {
  var btn = document.getElementById('theme-toggle');
  if (!btn) return;

  var states = ['system', 'light', 'dark'];

  function getNextState(current) {
    var idx = states.indexOf(current);
    if (idx === -1) { return states[0]; }
    return states[(idx + 1) % states.length];
  }

  function applyTheme(state) {
    
    if (state !== 'light' && state !== 'dark' && state !== 'system') {
      state = 'system';
    }

    if (state === 'system') {
      document.documentElement.removeAttribute('data-theme');
      document.documentElement.style.removeProperty('color-scheme');
      try { localStorage.removeItem('theme'); } catch(e) {}
      btn.setAttribute('aria-label', 'Currently using system theme. Switch to light mode');
    } else {
      document.documentElement.setAttribute('data-theme', state);
      document.documentElement.style.colorScheme = state;
      try { localStorage.setItem('theme', state); } catch(e) {}
      btn.setAttribute('aria-label',
        state === 'light'
          ? 'Currently light mode. Switch to dark mode'
          : 'Currently dark mode. Switch to system mode'
      );
    }

    
    var icon = btn.querySelector('.toggle-icon');
    if (icon) {
      icon.textContent = state === 'dark' ? '☀️' : state === 'light' ? '🌙' : '💻';
    }
  }

  btn.addEventListener('click', function () {
    var current;
    try { current = localStorage.getItem('theme'); } catch(e) { current = null; }
    current = (current === 'light' || current === 'dark') ? current : 'system';
    var next = getNextState(current);
    applyTheme(next);
  });

  
  var initial;
  try { initial = localStorage.getItem('theme'); } catch(e) { initial = null; }
  initial = (initial === 'light' || initial === 'dark') ? initial : 'system';
  document.body.classList.add('no-transition');
  applyTheme(initial);
  requestAnimationFrame(function () {
    requestAnimationFrame(function () {
      document.body.classList.remove('no-transition');
    });
  });
})();

Listening for System-Level Changes

When no manual override is stored, the page should react live if the user changes their OS color scheme preference.


window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
  
  var stored;
  try { stored = localStorage.getItem('theme'); } catch(err) { stored = null; }
  if (!stored) {
    
    
    var btn = document.getElementById('theme-toggle');
    if (btn) {
      var isDark = e.matches;
      var icon = btn.querySelector('.toggle-icon');
      if (icon) { icon.textContent = '💻'; }
      btn.setAttribute(
        'aria-label',
        isDark
          ? 'Currently dark (system). Switch to light mode'
          : 'Currently light (system). Switch to dark mode'
      );
    }
  }
});

Testing and Debugging Dark Mode

DevTools Emulation

Chrome DevTools provides a rendering panel (accessible via the three-dot menu or Cmd+Shift+P, then “Show Rendering”) with an “Emulate CSS media feature prefers-color-scheme” dropdown. Firefox’s Inspector offers a color-scheme simulation toggle directly in the toolbar. Both allow rapid testing without changing OS settings.

Accessibility Checks

Contrast ratios must pass WCAG AA (4.5:1 for normal text, 3:1 for large text) in both themes. The DevTools color picker in Chrome and Firefox shows contrast ratios inline. Running Lighthouse audits in both emulated schemes catches issues that manual inspection might miss. Additionally, test with Windows High Contrast Mode (forced-colors) to ensure the page remains usable when the OS overrides all colors.

Common Pitfalls

Forgetting to declare color-scheme with both light and dark keywords is the most common bug. Without it, light-dark() silently resolves to the first value, and the dark theme never appears. No error is thrown.

Hard-coded colors in component CSS bypass the token system entirely. A single color: #333 in a component rule looks fine in light mode but turns nearly invisible against a dark background. Audit stylesheets for any color values that do not reference custom properties, and consider linting rules that flag raw color literals outside your token file.

Images and illustrations designed for light backgrounds often look jarring in dark mode. The <picture> element with media="(prefers-color-scheme: dark)" allows serving alternate assets per scheme, though this does not respond to the JavaScript-driven data-theme override. To serve theme-aware images that respond to JS overrides, use <img> with JavaScript that swaps the src attribute based on the data-theme value, or use CSS background-image with selectors that key off [data-theme].

Important: Serve all pages via an HTTP server (e.g., python3 -m http.server or VS Code Live Server) rather than the file:// protocol, as localStorage is blocked on file:// in some browsers.

Putting It All Together

This tutorial produces three drop-in deliverables that form a complete no flash dark mode system:

  1. The CSS token system uses an @supports-separated light-dark() modern path and a prefers-color-scheme fallback block (Code examples 4, 5, 6, and 8). The token map defines surface colors, text colors, borders, accent colors, and shadows. You switch all of them by changing a single color-scheme value.
  2. An early-load inline script (Code example 7) sits in <head> before the stylesheet. It reads localStorage, applies the data-theme attribute, and sets color-scheme inline, preventing any visible flash on page load.
  3. The toggle script (Code examples 9 and 10) cycles through three states (system, light, dark), persists the choice to localStorage, updates aria-label for accessibility, and listens via matchMedia to keep the page in sync with OS changes when no manual override exists.

The system extends beyond colors. Additional token categories for spacing, typography scales, and border-radius values follow the same pattern. Teams building a full design-token architecture can layer these on top of the color tokens established here.

What This Enables Going Forward

The combination of color-scheme, light-dark(), and a small amount of JavaScript produces a flash-free, fallback-safe, framework-free dark mode system with zero token duplication. Components remain completely theme-agnostic. The progressive enhancement path through @supports guarantees that older browsers degrade gracefully rather than breaking.

The combination of color-scheme, light-dark(), and a small amount of JavaScript produces a flash-free, fallback-safe, framework-free dark mode system with zero token duplication. Components remain completely theme-agnostic.

CSS is gaining additional features that will eliminate more manual theme work. The @media (prefers-contrast) media feature allows adapting to user contrast preferences with the same token-based approach. The contrast-color() function, which became Baseline newly available in April 2026 (Chrome 147, Firefox 146, Safari 26), returns black or white, whichever contrasts better with a given color. It can replace some manual dark-mode text-color pairing, though it only picks black or white, so it will not replace a designed palette.

Adopting light-dark() now, while it has reached Baseline status across all major engines, positions a codebase to absorb these future capabilities without architectural changes. The token system built here is the foundation. A good next step: drop this token file into an existing project, run Lighthouse in both schemes, and fix every contrast failure that surfaces.

Leave a Comment