# You're doing dark mode wrong.
*A field guide for developers and designers.*
Your toggle has two states. Your users have three.
This is the markdown version of . The live page implements everything described here — it is its own demonstration. Source: (MIT, free to copy).
*Last updated: 2026-06-16.*
## 01 · 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-ask a question the OS already answered. "Follow the system" isn't an option.
- **✓ Three states** give an override when you want one — and a way back.
How that plays out over one evening:
1. **19:42 — Sunset.** The user's OS fades to dark — macOS *Auto*, iOS *Automatic*, Android's schedule, Windows via Auto Dark Mode. Every app follows.
2. **19:42 — Your site: still `#FFFFFF`.** Now the brightest thing on the screen.
3. **19:43 —** They click your toggle. `localStorage.theme = "dark"`. The page goes dark — and that value now overrides the system on every future visit.
4. **07:06 — 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
// ❌ 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.
```
## 02 · Why it matters
1. **The user already answered.** Every browser exposes the device's appearance via [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/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*](https://support.apple.com/guide/mac-help/use-a-light-or-dark-appearance-mchl52e1c2d2/mac), iOS has [*Automatic*](https://support.apple.com/en-us/108350) with a schedule, Android can switch on a schedule or [with Battery Saver](https://support.google.com/android/answer/9730472), and Windows does it via [PowerToys](https://learn.microsoft.com/en-us/windows/powertoys/light-switch) or [Auto Dark Mode](https://github.com/AutoDarkMode/Windows-Auto-Night-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](https://www.bram.us/2022/05/25/dark-mode-toggles-should-be-a-browser-feature/), 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](https://www.nngroup.com/articles/dark-mode/) 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](https://kilianvalkhof.com/2020/design/your-dark-mode-toggle-is-broken/). 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.
## 03 · 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.
| State | Storage | `` attribute |
| --- | --- | --- |
| Light | `"light"` | `data-theme="light"` |
| **Auto (the default)** | **(nothing)** | **(no attribute)** |
| Dark | `"dark"` | `data-theme="dark"` |
> 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
```
**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()`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/light-dark) picks every color's side automatically; an override is just `color-scheme` narrowed to one value.
```css
/* 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](https://caniuse.com/css-light-dark) (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]`. (The live 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
```
```js
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 .
mql.addEventListener('change', () => {
if (!localStorage.getItem('theme')) syncThemeColor();
});
```
**The fine print** — two gotchas that survive even good implementations. First: `` 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 *Auto* — [Apple's word in macOS settings](https://support.apple.com/guide/mac-help/use-a-light-or-dark-appearance-mchl52e1c2d2/mac); [iOS says *Automatic*](https://support.apple.com/en-us/108350). 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](https://www.androidauthority.com/dark-mode-poll-results-1090716/), 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."
## 04 · The demonstration
The live 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 shows its internals live: `localStorage.getItem('theme')` (null is the design — absence means Auto), `prefers-color-scheme` (what the OS is saying right now), `` (set only while overriding), and the effective scheme.
## 05 · The checklist
Audit your own site against these:
- [ ] The control has three states: Light, Dark, and Auto. (A checkbox cannot represent "follow my system." Radios or a select can.)
- [ ] Auto is the default for every first-time visitor. (First paint honors `prefers-color-scheme` — no flash of your favorite theme.)
- [ ] Storage holds overrides only — no key means Auto. (`removeItem` on Auto, never `setItem('auto')`. Absence is the state.)
- [ ] A `matchMedia` change listener keeps JS-rendered things in sync. (Charts, maps, embeds and meta theme-color don't read CSS variables on their own.)
- [ ] An inline, blocking head script applies the override before first paint. (Inline, above the stylesheet. `defer`/`async`/module all flash.)
- [ ] `color-scheme` is declared in both the meta tag and the CSS. (Scrollbars, form controls and the pre-CSS canvas follow the scheme for free.)
- [ ] `meta theme-color` gets rewritten from JS while overridden. (Its media attribute tracks the OS, not your `data-theme` attribute.)
- [ ] The theme swap respects `prefers-reduced-motion`. (Checked in JS before any view transition — not just in CSS.)
## 06 · Resources
### The argument
- [Your dark mode toggle is broken](https://kilianvalkhof.com/2020/design/your-dark-mode-toggle-is-broken/) — Kilian Valkhof, 2020. The canonical statement of the thesis: binary toggles trap users in a stored override, so the fix is exactly three options.
- [Dark Mode Toggles Should be a Browser Feature](https://www.bram.us/2022/05/25/dark-mode-toggles-should-be-a-browser-feature/) — Bramus Van Damme, 2022. Why overrides without a System option permanently disconnect a site from the user's preference.
- [It's tri-state switch time](https://www.brycewray.com/posts/2024/01/its-tri-state-switch-time/) — Bryce Wray, 2024. A practitioner converts his binary toggle after realizing it failed users who didn't want the site to futz with their defaults.
- [Dark Mode vs. Light Mode: Which Is Better?](https://www.nngroup.com/articles/dark-mode/) — Raluca Budiu, Nielsen Norman Group, 2020. The research review: light wins for normal vision, dark wins for some low-vision users — so let people switch.
### The build
- [The Quest for the Perfect Dark Mode](https://www.bram.us/2020/04/26/the-quest-for-the-perfect-dark-mode-using-vanilla-javascript/) — Bramus Van Damme, 2020. Vanilla-JS walkthrough: system as default, storage for overrides only, and a blocking head script against the theme flash.
- [Building a theme switch component](https://web.dev/articles/building/a-theme-switch-component) — Adam Argyle, web.dev. An accessible switch that reads the system preference by default, stores explicit choices, and live-syncs via matchMedia.
- [The Perfect Theme Switch Component](https://www.aleksandrhovhannisyan.com/blog/the-perfect-theme-switch/) — Aleksandr Hovhannisyan, 2024. A progressively enhanced Auto/Light/Dark picker built on color-scheme and light-dark(), with a CSS-only fallback and no FOUC.
- [Come to the light-dark() Side](https://css-tricks.com/come-to-the-light-dark-side/) — Sara Joy, CSS-Tricks, 2024. Deep dive on color-scheme + light-dark(), building a light/dark/auto switcher.
- [Three-State Light/Dark Theme Switch](https://tpiros.dev/blog/three-state-light-dark-theme-switch/) — Tamas Piros, 2024. Directly contrasts two-state vs three-state switches, with code for the control and flash prevention.
- [prefers-color-scheme: Hello darkness, my old friend](https://web.dev/articles/prefers-color-scheme) — Thomas Steiner, web.dev. The comprehensive dark-mode guide: respect the OS by default, then layer a persisted override on top.
- [Thinking on ways to solve a dark/light theme switch](https://web.dev/shows/gui-challenges/kZiS1QStIWc) — Adam Argyle, GUI Challenges (video). The UX and the code of a theme switch that syncs with the system by default.
- [dark-mode-toggle](https://github.com/GoogleChromeLabs/dark-mode-toggle) — GoogleChromeLabs. A maintained custom element that defaults to the system value and persists an override only when asked.
### The platform
- [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme) — MDN. The media feature that carries the user's answer. Baseline since 2020 — filed under accessibility guidance, not aesthetics.
- [light-dark()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/light-dark) — MDN. One function, both palettes — resolves against the element's used color-scheme. Baseline 2024.
- [color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme) — MDN. The property that declares which schemes you support — and the cleanest primitive for wiring up all three toggle states.
- [CSS Color Adjustment Module Level 1](https://www.w3.org/TR/css-color-adjust-1/) — W3C CSS Working Group. The standards basis: how author, user and user-agent color preferences negotiate. The "system" state lives here.
---
Default to the system. Store only overrides.