SuperAPI AI Directory

frontend-theming-dark-light-mode

Frontend & websites

View repository

Updated 10-16 04:33

About this skill

Frontend Theming : Dark and Light Mode

Authoritative reference for evergreen-2026 dark / light theming. The four primitives : color-scheme (declares what the page supports), light-dark() (per-property branch), @media (prefers-color-scheme: dark | light) (rules-block branch), and <picture><source media="(prefers-color-scheme: dark)"> (image branch).

Quick Reference

Baseline status

Feature Baseline Source
color-scheme Widely Available since January 2022 MDN : color-scheme (verified 2026-05-19)
prefers-color-scheme Widely Available since January 2020 MDN : prefers-color-scheme (verified 2026-05-19)
light-dark() Newly Available since May 2024; becoming Widely Available in 2026 MDN : light-dark() (verified 2026-05-19)

Minimum system-followed dark mode (no JS, no toggle)

<meta name="color-scheme" content="light dark">
:root {
  color-scheme: light dark;

  --bg:     light-dark(#ffffff, #0b0d12);
  --fg:     light-dark(#1a1a1a, #f5f5f5);
  --accent: light-dark(#2563eb, #60a5fa);
  --border: light-dark(#e2e8f0, #1f2937);
}

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

That is the entire system-followed dark mode. The browser flips between the two light-dark() branches based on the OS preference. Scrollbars, native form controls, and built-in UI render in the matching scheme.

Three-state toggle (system / light / dark)

User choice data-theme on <html> color-scheme resolved Persistence key
System (attribute absent) light dark (follows OS) theme=system (or storage absent)
Light data-theme="light" light (forced) theme=light
Dark data-theme="dark" dark (forced) theme=dark

Decision Trees

Tree 1 : System-followed only or user toggle?

Does the design call for dark mode following the OS exclusively?
   YES -> color-scheme: light dark on :root + light-dark() everywhere.
          NO JavaScript needed. Done.
   NO  -> add a toggle. Three-state recommended (system / light / dark).
          User choice persisted; system is the default.

Tree 2 : light-dark() or @media (prefers-color-scheme)?

Is the value a single color, length, or image used in ONE property?
   YES -> light-dark(<light>, <dark>). One line.

Are MANY properties branching together (whole rule block)?
   YES -> @media (prefers-color-scheme: dark) { .x { ... } } OR custom
          property pattern + light-dark() inside :root tokens.

Is the branching value NOT a color or image (e.g., `display`, `gap`,
custom property of unknown type)?
   YES -> @media query. light-dark() only accepts <color> or <image>.

Is browser support for light-dark() insufficient for your audience?
   YES -> @media query OR @supports (color: light-dark(red, red)) gate
          before relying on it.

Tree 3 : FOUC (Flash Of Unstyled Theme) prevention

Is the theme determined ENTIRELY by OS preference (no user override)?
   YES -> no flash possible. color-scheme + light-dark() resolve at
          parse time before paint. Done.

Does the user have an override stored in localStorage?
   YES -> apply the override BEFORE first paint via an inline <script>
          in <head>, BEFORE the stylesheet link. The script reads
          localStorage and sets the data-theme attribute synchronously.
          NEVER apply via useEffect / DOMContentLoaded (post-paint).

Is the user authenticated, server-side persistence used?
   YES -> server emits <html data-theme="..."> in the response. Zero
          flash, zero localStorage round-trip.

Patterns

Pattern A : System-only, zero JavaScript

<!doctype html>
<html lang="en">
<head>
  <meta name="color-scheme" content="light dark">
  <link rel="stylesheet" href="theme.css">
</head>
<body>...</body>
</html>
:root {
  color-scheme: light dark;

  --bg:     light-dark(#ffffff, #0b0d12);
  --fg:     light-dark(#0a0a0a, #f5f5f5);
  --accent: light-dark(#2563eb, #60a5fa);
}
body { background: var(--bg); color: var(--fg); }

The <meta name="color-scheme"> tag MUST be present so the browser knows during initial paint what schemes the page supports. This prevents the browser from rendering its own UI (scrollbars, default form-control colors) in the wrong scheme during the brief window before CSS arrives.

Pattern B : Three-state toggle with FOUC prevention

<!doctype html>
<html lang="en">
<head>
  <meta name="color-scheme" content="light dark">
  <script>
    // Runs SYNCHRONOUSLY in <head>, BEFORE the stylesheet link below.
    // Sets data-theme on <html> before first paint. ZERO flash.
    (function () {
      try {
        var t = localStorage.getItem('theme');
        if (t === 'light' || t === 'dark') {
          document.documentElement.setAttribute('data-theme', t);
        }
      } catch (e) { /* localStorage may be disabled */ }
    })();
  </script>
  <link rel="stylesheet" href="theme.css">
</head>
:root {
  color-scheme: light dark;

  --bg:     light-dark(#ffffff, #0b0d12);
  --fg:     light-dark(#0a0a0a, #f5f5f5);
  --accent: light-dark(#2563eb, #60a5fa);
}

/* Forced overrides : ALSO set color-scheme so UA UI matches */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark;  }

body { background: var(--bg); color: var(--fg); }
// Three-state toggle button handler
function setTheme(choice /* 'system' | 'light' | 'dark' */) {
  if (choice === 'system') {
    localStorage.removeItem('theme');
    document.documentElement.removeAttribute('data-theme');
  } else {
    localStorage.setItem('theme', choice);
    document.documentElement.setAttribute('data-theme', choice);
  }
}

Pattern C : Per-image dark variant

<!-- Best : <picture> handles image-source switching -->
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="hero-dark.webp">
  <img src="hero-light.webp" alt="" loading="lazy" width="800" height="400">
</picture>
/* Or, for CSS background-image (when light-dark() image-form is supported) */
@supports (background-image: light-dark(url(a.png), url(b.png))) {
  .hero {
    background-image: light-dark(url(hero-light.webp), url(hero-dark.webp));
  }
}

Pattern D : Listening for system changes (only when override mode = system)

const mql = window.matchMedia('(prefers-color-scheme: dark)');

function onSystemChange(e) {
  // ONLY meaningful if the user is on "system" choice.
  // If user picked light or dark explicitly, ignore.
  const userChoice = localStorage.getItem('theme');
  if (userChoice === 'light' || userChoice === 'dark') return;

  // System change auto-applies via light-dark(); usually no JS work
  // is required. Update aria-pressed state on the toggle UI if you have one.
  updateTogglePressedStates();
}

mql.addEventListener('change', onSystemChange);

// Cleanup on SPA navigation away :
function teardown() {
  mql.removeEventListener('change', onSystemChange);
}

Pattern E : aria-pressed on toggle buttons

<div role="group" aria-label="Theme">
  <button type="button" data-choice="system" aria-pressed="true">System</button>
  <button type="button" data-choice="light"  aria-pressed="false">Light</button>
  <button type="button" data-choice="dark"   aria-pressed="false">Dark</button>
</div>

Update aria-pressed whenever the choice changes. Screen-reader users hear the active state.

Pattern F : Smoothing the transition between modes (optional)

:root {
  transition:
    background-color 200ms ease,
    color            200ms ease;
}

Apply only to top-level color tokens to avoid a cascading transition on every property. Skip entirely if prefers-reduced-motion: reduce.

@media (prefers-reduced-motion: reduce) {
  :root { transition: none; }
}

Out of Scope

  • Design-token infrastructure, multi-tier semantic tokens, theme generators (covered in [[frontend-impl-design-tokens]]).
  • OKLCH palette generation, perceptually-uniform color scales (covered in [[frontend-theming-color-palette-oklch]]).
  • Contrast checks and WCAG 1.4.3 / 1.4.11 compliance (covered in [[frontend-a11y-motion-contrast-wcag22]]).
  • Per-component theme overrides (covered in [[frontend-impl-design-tokens]]).

Hard Rules (Binding)

  1. NEVER call light-dark(a, b) without declaring color-scheme: light dark (or light / dark) on the element or an ancestor. Without it, light-dark() always returns the light branch.
  2. NEVER apply a stored theme in useEffect / DOMContentLoaded / post-paint code. Use the head-script pattern, executed synchronously BEFORE the first stylesheet, to avoid FOUC.
  3. NEVER omit color-scheme on a dark-themed page. Without it, scrollbars and native form controls stay in the light UA scheme even though your CSS is dark, creating visible mismatch.
  4. NEVER duplicate every rule in @media (prefers-color-scheme: dark) when light-dark() covers it. Reserve the media query for cases light-dark() cannot handle (multiple non-color values, images on older browsers).
  5. NEVER force a single theme regardless of the OS preference unless the design explicitly demands a single-theme product. Default to system; offer override as user choice.
  6. NEVER omit aria-pressed (for toggles) or aria-checked (for switch-like groups) on theme controls. Screen-reader users need to perceive the active state.
  7. ALWAYS set color-scheme: light or color-scheme: dark in your forced-mode CSS rule (e.g., :root[data-theme="dark"]), not only your color tokens. Otherwise UA-rendered UI fights the page.
  8. ALWAYS pair the forced-mode attribute with the same value in localStorage and the head-script. The two MUST be the source of truth in sync.

Reference Links

Cross-References

  • [[frontend-syntax-css-color-modern]] : oklch(), color-mix(), modern color value model
  • [[frontend-theming-color-palette-oklch]] : palette generation from a brand seed in perceptual space
  • [[frontend-impl-design-tokens]] : tier model (primitive -> semantic -> component) for tokens that drive light-dark() values
  • [[frontend-a11y-motion-contrast-wcag22]] : WCAG 1.4.3 Contrast (Minimum) and 1.4.11 Non-Text Contrast applied to both modes
  • [[frontend-syntax-css-cascade-layers-scope]] : @layer tokens, theme, base, components, utilities; placement for theme rules