Implementing Dark Mode With a Theme Layer

Dark mode goes wrong in a predictable way: it starts as a token flip, then a component needs “just one override”, and eighteen months later the dark scheme is a parallel design system maintained by nobody. Keeping it in the theme layer is what prevents that — a scheme is a set of token values, and every component should be unaware which one is active. This applies theme token layer mapping from the architecture patterns reference.

Prerequisites

Your design system needs two token tiers: primitives (raw values) and semantic aliases (what a value is for). If components currently reference primitives directly, do that split first — it is the change that makes dark mode a two-hour job instead of a two-month one.

/* WHY `theme` sits above `base`: primitives are declared once in `base`, and
   `theme` re-points the semantic aliases at different primitives per scheme.
   Components read only the aliases and never learn which scheme is active. */
@layer reset, base, theme, components, utilities;

Step 1: Separate primitives from semantic aliases

@layer base {
  /* WHY: primitives are scheme-agnostic facts. `--olive-600` is the same
     colour in both schemes; what changes is which primitive a semantic
     alias points at. */
  :root {
    --olive-300: #bcd177;
    --olive-600: #5a6830;
    --sand-50:   #fffdf5;
    --sand-100:  #f7f3e9;
    --bark-800:  #3b3522;
    --bark-950:  #191710;
  }
}

@layer theme {
  /* WHY: components consume ONLY these names. Adding a scheme means adding
     one block here, not touching a single component file. */
  :root {
    --color-bg:      var(--sand-100);
    --color-surface: var(--sand-50);
    --color-text:    var(--bark-800);
    --color-primary: var(--olive-600);
  }
}

What this does: establishes the indirection the whole approach depends on. The test for whether you have it: search the components layer for a hex value. Every hit is a place dark mode will be wrong.

Why the indirection makes dark mode a one-file change Three bands. The base layer holds scheme-agnostic primitives. The theme layer holds semantic aliases that point at different primitives in light and dark. The components layer consumes only the aliases, so it never changes when a scheme is added. @layer base — primitives --sand-50 --sand-100 --bark-800 --bark-950 --olive-300 --olive-600 @layer theme · light --color-bg → --sand-100 --color-text → --bark-800 default scheme @layer theme · dark --color-bg → --bark-950 --color-text → --sand-50 [data-theme="dark"] @layer components — reads aliases only .card { background: var(--color-surface); color: var(--color-text); } ← unchanged by either scheme

Step 2: Declare both schemes, twice

@layer theme {
  /* Explicit choice — set by the head script from localStorage or the OS. */
  [data-theme="dark"] {
    color-scheme: dark;                       /* WHY: flips UA form controls
                                                 and scrollbars too */
    --color-bg:      var(--bark-950);
    --color-surface: #221f16;
    --color-text:    #f2ecdb;
    --color-primary: var(--olive-300);        /* lighter — legible on dark */
  }

  /* WHY the duplicate: a visitor with JavaScript disabled has no data-theme
     attribute at all. This branch follows the OS unless they explicitly
     chose light, so no-JS dark users are not left on a white page. */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      color-scheme: dark;
      --color-bg:      var(--bark-950);
      --color-surface: #221f16;
      --color-text:    #f2ecdb;
      --color-primary: var(--olive-300);
    }
  }
}

What this does: covers both resolution paths. The duplication is deliberate — CSS has no way to share a declaration block between a selector and a media query, and a preprocessor mixin is the only way to remove it.

Step 3: Resolve the theme before first paint

<head>
  <!-- WHY inline and synchronous, BEFORE the stylesheet links: the attribute
       must exist when the first paint happens. A deferred or external script
       paints the wrong scheme and then corrects it — the flash everyone
       recognises. -->
  <script>
    (function () {
      try {
        var stored = localStorage.getItem('theme');
        var dark = stored === 'dark' ||
          (!stored && matchMedia('(prefers-color-scheme: dark)').matches);
        document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
      } catch (e) {
        document.documentElement.setAttribute('data-theme', 'light');
      }
    })();
  </script>
  <link rel="stylesheet" href="/css/index.css">
</head>

What this does: removes the flash. The try/catch matters — localStorage throws in some privacy modes, and an uncaught error here leaves the page with no theme attribute at all.

Step 4: Add an accessible toggle

// WHY aria-pressed and a synced label: a toggle whose accessible name never
// changes tells a screen-reader user nothing about the current state.
const btn = document.querySelector('[data-theme-toggle]');
const root = document.documentElement;

function apply(theme) {
  root.setAttribute('data-theme', theme);
  btn.setAttribute('aria-pressed', String(theme === 'dark'));
  btn.setAttribute('aria-label',
    theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
}

btn.addEventListener('click', () => {
  const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
  try { localStorage.setItem('theme', next); } catch { /* private mode */ }
  apply(next);
});

// WHY: follow the OS while no explicit choice is stored, so a system-wide
// switch at sunset is respected without the user touching the button.
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  let stored = null;
  try { stored = localStorage.getItem('theme'); } catch { /* ignore */ }
  if (!stored) apply(e.matches ? 'dark' : 'light');
});

What this does: gives a keyboard-and-screen-reader-usable control that persists, and keeps following the OS until the user overrides it.

Step 5: Extend the flip to every surface

@layer components {
  /* WHY named here: these are the surfaces that most often get forgotten,
     because they are styled once early and never revisited. */
  pre, code       { background: var(--color-code-bg); color: var(--color-code-text); }
  table th        { background: var(--color-surface-alt); }
  input, select   { background: var(--color-surface); color: var(--color-text); }
  ::placeholder   { color: var(--color-text-muted); }

  /* WHY currentColor in diagrams: an inline SVG drawn with currentColor
     inherits the text colour, so it flips with the scheme automatically.
     A hard-coded #333 stroke silently stays dark-on-dark. */
  svg[role="img"] { color: var(--color-text); }
}

What this does: catches the surfaces a first pass misses. Syntax-highlighting token colours deserve particular attention: a palette tuned for a light background usually fails contrast on a dark one.

Surfaces that get forgotten in a dark-mode pass Two columns. The left column lists surfaces that are usually handled: page background, body text, links and headings. The right column lists commonly missed surfaces: code block backgrounds, syntax token colours, table headers, form controls, focus rings and inline SVG interiors. usually handled on the first pass commonly missed page background and body text code block background and border headings and links syntax-highlighting token colours buttons and cards table header and zebra rows the header and footer chrome form controls and placeholders borders and dividers focus rings and shadows inline SVG interiors and chart marks — because you develop in this scheme

Verification

  1. Load the page with the OS set to dark and JavaScript disabled. The media-query branch must produce the dark scheme.
  2. Toggle, reload, and confirm the choice persists and no flash occurs. Throttle the network to make a flash visible if there is one.
  3. Run an automated contrast audit in both schemes — most tools only test the scheme the headless browser happens to be in:
Four checks before shipping a second scheme Four verification steps for a dark theme: no flash on load, contrast audited in both schemes, inline SVG diagrams following the text colour, and native controls flipping with the colour-scheme property. no flash on first paint the resolution script must be inline and synchronous contrast audited in BOTH schemes most tools only test the one the runner is in inline SVGs follow the text colour currentColor flips for free; a literal hex does not native controls flip too that follows color-scheme, not your tokens
npx @axe-core/cli http://localhost:4173 --rules color-contrast
  1. Check every inline SVG in dark mode. A diagram that uses currentColor flips; one with a literal fill does not, and the failure is silent.
  2. Confirm color-scheme is set, then check native scrollbars and date pickers — they follow that property rather than your tokens.

Troubleshooting

A component is unreadable in dark mode. : It references a primitive or a literal hex instead of a semantic alias. Grep the components layer for # and replace each hit with the alias that describes the role.

The page flashes light before going dark. : The resolution script is deferred, external, or placed after the stylesheet. It must be inline, synchronous, and before the first <link rel="stylesheet">.

Dark mode works but the code blocks stay light. : Syntax-highlighting themes ship their own colours outside your token system. Map every token colour to a variable and provide dark values, or the highlighter keeps its own palette regardless of the scheme.

Contrast passes in light and fails in dark. : A brand colour tuned for a white background is usually too dark for a dark one. Point the semantic alias at a lighter primitive in the dark block rather than adjusting the component.

The toggle works but the OS switch no longer does. : The click handler writes to storage unconditionally, so the “no explicit choice” state is unreachable after the first click. Offer a third state (“system”) if you want the OS to keep control after a user has toggled once.

Complete working example

/* ============================================================
   Two schemes from one theme layer — complete and self-contained
   ============================================================ */
@layer reset, base, theme, components, utilities;

@layer base {
  /* WHY primitives, not semantics: these names describe the colour, so they
     are identical in both schemes and can be referenced by either. */
  :root {
    --sand-50:  #fffdf5;  --sand-100: #f7f3e9;  --sand-200: #f0ead6;
    --bark-800: #3b3522;  --bark-900: #221f16;  --bark-950: #191710;
    --olive-300:#bcd177;  --olive-600:#5a6830;
    --amber-300:#f0d271;  --amber-600:#c49a2a;
  }
}

@layer theme {
  /* Light — the default, and the fallback for anything unresolved. */
  :root {
    color-scheme: light;
    --color-bg:          var(--sand-100);
    --color-surface:     var(--sand-50);
    --color-surface-alt: var(--sand-200);
    --color-text:        var(--bark-800);
    --color-text-muted:  #6b5e3e;
    --color-primary:     var(--olive-600);
    --color-on-primary:  #ffffff;
    --color-focus:       var(--amber-600);
    --color-code-bg:     var(--sand-200);
    --color-code-text:   var(--bark-800);
  }

  /* Dark — an explicit choice, resolved by the head script. */
  [data-theme="dark"] {
    color-scheme: dark;
    --color-bg:          var(--bark-950);
    --color-surface:     var(--bark-900);
    --color-surface-alt: #2b271b;
    --color-text:        #f2ecdb;
    --color-text-muted:  #cabf9f;
    --color-primary:     var(--olive-300);
    --color-on-primary:  #14120c;   /* WHY dark: the primary is now light */
    --color-focus:       var(--amber-300);
    --color-code-bg:     #221f15;
    --color-code-text:   #ece5d2;
  }

  /* Dark — no JavaScript, follow the OS unless light was chosen. */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      color-scheme: dark;
      --color-bg:          var(--bark-950);
      --color-surface:     var(--bark-900);
      --color-surface-alt: #2b271b;
      --color-text:        #f2ecdb;
      --color-text-muted:  #cabf9f;
      --color-primary:     var(--olive-300);
      --color-on-primary:  #14120c;
      --color-focus:       var(--amber-300);
      --color-code-bg:     #221f15;
      --color-code-text:   #ece5d2;
    }
  }
}

@layer components {
  /* WHY no scheme awareness anywhere below: every rule reads an alias, so
     adding a third scheme (high contrast, sepia) touches only the theme
     layer above. */
  body { background: var(--color-bg); color: var(--color-text); }

  .card {
    background: var(--color-surface);
    border: 1px solid var(--color-border, var(--color-surface-alt));
    color: var(--color-text);
  }

  .btn--primary {
    background: var(--color-primary);
    color: var(--color-on-primary);
  }

  pre, code { background: var(--color-code-bg); color: var(--color-code-text); }

  :focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

  /* WHY: diagrams drawn with currentColor inherit this and flip for free. */
  svg[role="img"] { color: var(--color-text); }

  /* WHY reduced motion: the toggle should not animate a full-page colour
     change for readers who asked for less movement. */
  @media (prefers-reduced-motion: no-preference) {
    body { transition: background-color 0.2s ease, color 0.2s ease; }
  }
}

Frequently Asked Questions

Why put dark mode in the theme layer rather than in components?

Because a colour scheme is a set of token values, not a set of component behaviours. If dark declarations live in component rules, every component ever added has to remember to implement both schemes, and the number of places a colour can be wrong grows without limit. A theme layer that re-points semantic aliases keeps that number at one, and a third scheme later costs one more block rather than a sweep through the whole design system.

How do you avoid the flash of the wrong theme?

Resolve the scheme in a small synchronous script placed in the head before any stylesheet link. It reads the stored choice or the OS preference and sets a data attribute on the root element, so the very first paint already has the right tokens. Any deferred or external script necessarily runs after the first paint, which is exactly the flash it was meant to prevent — and wrap the storage access in a try/catch, because it throws in some privacy modes.

Do inline SVG diagrams need special handling?

Only if they contain literal colours. A diagram drawn with currentColor inherits the text colour and flips with the scheme at no cost, which is the reason to author them that way from the start. Anything with a hard-coded fill — a chart series, a brand mark, a status colour — needs either a token or an explicit dark value, and the failure mode is silent: the shape simply stays light-on-dark until someone looks at that page in the other scheme.

Up: Theme Token Layer MappingArchitecture Patterns & Design System Scaling