Starter Core concepts Dark Mode

Core concept 03

Dark Mode Done Right

Dark mode is a token concern, not a component concern. Centralise theme overrides in one place — every component updates automatically, including ones you haven't written yet.

The wrong way

The most common dark mode implementation scatters overrides across every component file. Each new component requires its own [data-theme="dark"] block. The theming concern is distributed everywhere.

✗ Dark mode in the component
.card { background: white; color: #0d0d0d; border: 1px solid #e5e5e5; } [data-theme="dark"] .card { background: #1c1c1c; color: #f9f9f9; border-color: #2a2a2a; } /* Every component needs the same... */ [data-theme="dark"] .btn { ... } [data-theme="dark"] .input { ... }
✓ Dark mode in the token layer
@layer tokens { [data-theme="dark"] { --surface-raised: var(--color-gray-900); --text-primary: var(--color-gray-50); --border-subtle: #2a2a2a; } } @layer components { .card { background: var(--surface-raised); color: var(--text-primary); border: 1px solid var(--border-subtle); /* No dark mode override needed */ } }

How it works

The entire dark mode implementation lives in css/tokens.css, inside @layer tokens. When the data-theme="dark" attribute is set on <html>, the semantic token values change. Every component that references those tokens updates automatically.

css/tokens.css (simplified)
@layer tokens {

  /* Tier 2 — Light (default) */
  :root, [data-theme="light"] {
    --surface-page:   var(--color-gray-50);
    --surface-raised: var(--color-white);
    --text-primary:   var(--color-gray-950);
    --accent-default: var(--color-amber-500);
  }

  /* Tier 2 — Dark overrides */
  [data-theme="dark"] {
    --surface-page:   var(--color-black);
    --surface-raised: var(--color-gray-900);
    --text-primary:   var(--color-gray-50);
    --accent-default: var(--color-amber-400);
  }

  /* OS preference as fallback — only when no manual override is set */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --surface-page:   var(--color-black);
      --surface-raised: var(--color-gray-900);
      --text-primary:   var(--color-gray-50);
    }
  }
}

Components are mode-agnostic by default

Because components only reference semantic tokens, every new component you write automatically supports both light and dark mode. There is no dark mode work required at the component level — ever.

Preventing flash of wrong theme

If the theme is applied via JavaScript after the page renders, users see a brief flash of the wrong theme. The fix is a small inline script in <head> that runs synchronously before any content renders.

This script must be inline

An external script file would introduce a round-trip request and still flash. The script must be inline in <head>, and it must run before the stylesheet is applied. Do not move it, defer it, or make it async.

Every HTML page — in <head>, before the stylesheet
<!-- Anti-flash: sets data-theme before paint -->
<script>
  (function () {
    var stored = null;
    try { stored = localStorage.getItem('theme-preference'); } catch (e) {}
    var theme = stored
      || (window.matchMedia('(prefers-color-scheme: dark)').matches
          ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

Priority logic

The script resolves the active theme in this order:

  1. Check localStorage for an explicit user preference
  2. Fall back to prefers-color-scheme (OS setting)
  3. Default to light if neither is available

The toggle

The theme toggle button is included in the nav on every page. The JavaScript in js/main.js handles reading and writing the preference.

js/main.js — Theme module (simplified)
// Toggle between dark and light, persist to localStorage
function toggle() {
  const current = html.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  html.setAttribute('data-theme', next);
  localStorage.setItem('theme-preference', next);
}

This page is the demo — toggle the theme in the top-right corner

Every surface, border, and text colour on this page responds to the toggle. The component CSS has not changed — only the semantic token values swap in tokens.css.

Rules to follow

RuleWhy
Never use hardcoded colours in components They won't respond to theme changes
Never add dark mode overrides in component files Dark mode lives in tokens.css only
Always reference semantic tokens, not primitives Primitives don't change between themes
Keep the anti-flash script inline in <head> External scripts cause flash
Use :root:not([data-theme="light"]) for OS media query Prevents media query from overriding explicit user choice