You’re doing dark mode wrong.
Theme

A field guide for developers & designers

You’re doing dark mode wrong.

Your toggle has two states.
Your users have three.

This page follows your system’s appearance. If you can read this, JavaScript isn’t running — and the theme still matches your system. Auto works without it.

Why two states aren’t enough
the problem

The two-state toggle

Most theme toggles have two states: light and dark. Neither means what many users actually want — “just match my device.”

✕  two states Re-asks a question the OS already answered. “Follow the system” isn’t an option.
✓  three states An override when you want one — and a way back.

How that plays out over one evening:

  1. Sunset. Their OS fades to dark — macOS Auto, iOS Automatic, Android’s schedule, Windows via Auto Dark Mode. Every app follows.

  2. Your site: still #FFFFFF. Now the brightest thing on the screen.

  3. They click your toggle. localStorage.theme = "dark". The page goes dark — and that value now overrides the system on every future visit.

  4. Sunrise. The OS returns to light. Every app follows — except your site, pinned by a value written ten hours ago. It will now disagree with the device twice a day.

  5. No way back to automatic. The toggle still switches between light and dark, but “follow my system” is gone — the UI has no state for it. Staying in sync is now manual work; the only reset is clearing site data, a step few would connect to a theme.

The code usually looks like this:

js two-state-toggle.js — the anti-pattern, annotated
//  the two-state toggle, with its three bugs annotated

const theme = localStorage.getItem('theme') || 'light';
// BUG 1 — defaults to 'light' for every first-time visitor.
// The system preference is available, but never consulted.

document.documentElement.dataset.theme = theme;
// …and because the attribute is ALWAYS set, the CSS
// prefers-color-scheme media query can never apply. Auto is unreachable.

toggle.addEventListener('click', () => {
  const next = html.dataset.theme === 'dark' ? 'light' : 'dark';
  localStorage.setItem('theme', next);
  // BUG 2 — stored permanently after the first click. There is no
  // third state, so there is no way to hand control back.
  html.dataset.theme = next;
});

// BUG 3 — nowhere in this file:
//   matchMedia('(prefers-color-scheme: dark)')
// When the OS switches at sunset, this page won’t follow.
the argument

Why it matters

  1. The user already answered.

    Every browser exposes the device’s appearance via prefers-color-scheme — Baseline since 2020. The preference is on record before the user arrives; a toggle that defaults to light simply ignores it.

  2. Operating systems follow the sun. A stored toggle can’t.

    macOS has Auto, iOS has Automatic with a schedule, Android can switch on a schedule or with Battery Saver, and Windows does it via PowerToys or Auto Dark Mode. Appearance changes mid-session — and a stored binary value can never follow.

  3. One click disconnects the site permanently.

    Try the other theme once, and the choice is stored for good. As Bramus Van Damme puts it, such sites “will never be able to respond to the system preference again, as they always have an override applied.”

  4. It’s an accessibility signal, not a vibe.

    Photophobia and migraines push some users to dark; astigmatism halation pushes others to light; NN/g’s research review found no default that suits everyone. That’s why the OS lets each user pick — and why overriding the signal is the same class of bug as ignoring prefers-reduced-motion.

  5. Everything else on the device already behaves this way.

    Native apps follow the system. The OS settings panel is a tri-state — so are Stack Overflow’s and GitHub’s theme controls. When a site doesn’t follow, the mismatch reads as a bug in the site, not in the settings.

  6. The fix costs one enum value and one event listener.

    Both versions need two themes in CSS, a stored preference, a control, and a head script against theme flash. The difference is treating “nothing stored” as a state and listening for system changes — a few lines.

the fix

Three states, with Auto as the default

The model: light and dark are stored overrides; auto means nothing is stored. Once “no stored value” means “follow the system,” the rest falls into place.

Auto isn’t a third theme — it’s the absence of an override.

Step one — the head. Declare scheme support before any CSS, and apply a stored override synchronously, before first paint. The script must be inline and blocking — external files, defer, async and modules all run too late and flash the wrong theme.

html index.html — the <head>, in this order
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f4f5f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0b0d" media="(prefers-color-scheme: dark)">

<!-- inline + blocking, BEFORE the stylesheet -->
<script>
  try {
    const t = localStorage.getItem('theme');
    if (t === 'light' || t === 'dark')
      document.documentElement.dataset.theme = t;
    // no stored key? do nothing — CSS handles auto by itself
  } catch (e) {}
</script>

<link rel="stylesheet" href="/styles.css">

Step two — the CSS. Three lines plus your colors. color-scheme opts you into both schemes — form controls, scrollbars and the default canvas match the OS for free. light-dark() picks every color’s side automatically; an override is just color-scheme narrowed to one value.

css styles.css — the entire theming system
/* 1 · support both schemes; auto now works with zero JS */
:root { color-scheme: light dark; }

/* 2 · an explicit choice just narrows the scheme */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

/* 3 · every color picks its own side — no duplicated palettes,
       no media queries, correct in all three states */
body {
  background: light-dark(#f4f5f7, #0a0b0d);
  color:      light-dark(#0d0f13, #eef1f5);
}

light-dark() is Baseline 2024 (Chrome 123, Edge 123, Firefox 120, Safari 17.5). For older browsers, same model in classic syntax: tokens in :root, overridden in @media (prefers-color-scheme: dark) guarded by :root:not([data-theme]), then re-asserted under each [data-theme]. (This page ships that fallback — view source.)

Step three — the control and the logic. Radio buttons give you keyboard support and screen-reader semantics for free. The logic enforces one invariant: storage holds overrides only.

html the control — three real states, not a checkbox
<fieldset>
  <legend>Theme</legend>
  <label><input type="radio" name="theme" value="light"> Light</label>
  <label><input type="radio" name="theme" value="auto" checked> Auto</label>
  <label><input type="radio" name="theme" value="dark"> Dark</label>
</fieldset>
js theme.js — the whole thing
const mql = matchMedia('(prefers-color-scheme: dark)');

function setTheme(choice) {           // 'light' | 'dark' | 'auto'
  if (choice === 'auto') {
    localStorage.removeItem('theme'); // absence IS the auto state
    delete document.documentElement.dataset.theme;
  } else {
    localStorage.setItem('theme', choice);
    document.documentElement.dataset.theme = choice;
  }
  syncThemeColor();                   // see the fine print below
}

document.querySelectorAll('input[name="theme"]').forEach((input) => {
  input.addEventListener('change', () => setTheme(input.value));
});

// In auto mode, CSS already follows OS changes live — this listener
// is for everything CSS can't reach: canvas charts, maps, embeds,
// and <meta name="theme-color">.
mql.addEventListener('change', () => {
  if (!localStorage.getItem('theme')) syncThemeColor();
});

The fine print — two gotchas that survive even good implementations. First: <meta name="theme-color"> media attributes track the OS only, never your data-theme attribute — while overridden, rewrite the metas from JS. Second: if you animate the swap, gate it behind matchMedia('(prefers-reduced-motion: reduce)') in the JS, not just CSS.

objections, anticipated

“A two-state toggle is simpler.”

Simpler to draw, not simpler to use: it breaks OS sync permanently after one click. Auto is the default, so most users never open the control at all — and the ones who do can finally say “stop overriding.”

“Users don’t understand what ‘System’ means.”

Label it AutoApple’s word in macOS settings; iOS says Automatic. Stack Overflow, GitHub and the OS panels all ship the tri-state — it’s the familiar pattern. A user who ignores it is unaffected: Auto just makes the site match their device.

“Nobody changes their OS theme anyway.”

Those users are exactly who Auto serves: the site inherits whatever their device does, no effort required. And the users who did configure something aren’t an edge case — in Android Authority’s 2,514-vote poll, 81.9% used dark mode everywhere it was offered.

“We initialize the toggle from prefers-color-scheme, so we do respect it.”

Only until the first click. A persisted value shadows the media query permanently, and can’t follow the sunset switch mid-session. Respecting the preference once isn’t staying subscribed to it.

“We clear the stored choice when it matches the system — that’s our way back.”

That fixes one direction and breaks the other. Picking Dark while the system is dark silently becomes Auto — so at sunrise the site flips to light, against an explicit request. The user can no longer pin a theme that happens to match their current system. A third state states the intent; this trick guesses it.

“Fine — then we just won’t persist the choice at all.”

That breaks the opposite contract: someone who wants your site light on a dark device now re-toggles every visit. Three states honor both intents — choices persist, and Auto means “no override.”

the demonstration

Watch this page do it

This page runs exactly the code above — view source; it’s one file. With the header toggle on Auto, change your OS appearance and watch the page follow, no reload:

macOS System Settings → Appearance → Light / Dark / Auto
iOS Settings → Display & Brightness
Windows Settings → Personalization → Colors → Choose your mode
Android Settings → Display → Dark theme

The page’s actual internals, updated live:

under the hood · updates live
localStorage.getItem('theme') null is the design — absence means Auto
prefers-color-scheme what your OS is saying, right now
<html data-theme> set only while you’re overriding
effective scheme what you’re looking at
before you ship

The checklist

Audit your own site against these. (Ticking the boxes stores nothing — this page only writes to storage when you override the theme.)

go deeper

Resources

the build

tutorial · 2020 The Quest for the Perfect Dark Mode Bramus Van Damme

Vanilla-JS walkthrough: system as default, storage for overrides only, and a blocking head script against the theme flash.

bram.us
tutorial · web.dev Building a theme switch component Adam Argyle

An accessible switch that reads the system preference by default, stores explicit choices, and live-syncs via matchMedia.

web.dev
tutorial · 2024 The Perfect Theme Switch Component Aleksandr Hovhannisyan

A progressively enhanced Auto/Light/Dark picker built on color-scheme and light-dark(), with a CSS-only fallback and no FOUC.

aleksandrhovhannisyan.com
tutorial · 2024 Come to the light-dark() Side Sara Joy · CSS-Tricks

Deep dive on color-scheme + light-dark(), building a light/dark/auto switcher and arguing for all three as discrete options.

css-tricks.com
tutorial · 2024 Three-State Light/Dark Theme Switch Tamas Piros

Directly contrasts two-state vs three-state switches, with code for the light/dark/system control and flash prevention.

tpiros.dev
guide · web.dev prefers-color-scheme: Hello darkness, my old friend Thomas Steiner

The comprehensive dark-mode guide: respect the OS by default, then layer a persisted override on top.

web.dev
video · gui challenges Thinking on ways to solve a dark/light theme switch Adam Argyle

The UX and the code of a theme switch that syncs with the system by default, on video.

web.dev
component · github <dark-mode-toggle> GoogleChromeLabs

A maintained custom element that defaults to the system value and persists an override only when asked.

github.com