Skip to content

Dark Mode: System Preference, Toggle, Persistence

Wire prefers-color-scheme, an explicit toggle, and persistence without a flash, and keep the result accessible.

5 min read
Dark Mode
prefers-color-scheme
Accessibility
Theming
CSS

Design tokens gave us a semantic layer to swap. Dark mode is the first theme that proves whether that layer was real. The failure mode is familiar: a white flash on load, a toggle that fights the OS, or a "dark" palette that fails contrast and motion preferences.

I want three behaviors to compose cleanly: follow the system, honor an explicit choice, and remember that choice: without painting the wrong theme for a frame.

The resolution order

Resolve theme in this order every time:

  1. Explicit user preference (light | dark) if stored
  2. Else prefers-color-scheme
  3. Else a documented default (usually light for content sites; product apps may default to system)

Never invent a fourth source ("what marketing wanted this sprint") at runtime.

ts
// src/theme/resolve.ts, illustrative export type ThemePreference = 'light' | 'dark' | 'system'; export function resolveTheme( preference: ThemePreference, systemDark: boolean, ): 'light' | 'dark' { if (preference === 'light' || preference === 'dark') return preference; return systemDark ? 'dark' : 'light'; }

system is a first-class preference, not "no preference." Users who choose system expect OS changes to flow through.

Semantic tokens under [data-theme]

Semantic variables from the design-tokens post swap under a root attribute (or class). I prefer data-theme because it is explicit in DevTools and easy to target.

css
/* styles/tokens.css, light defaults on :root; dark overrides */ :root, [data-theme='light'] { --color-fg: #0a0a0a; --color-bg: #ffffff; --color-muted: #6b7280; --color-border: #e5e7eb; --color-accent: #2563eb; --color-accent-fg: #ffffff; } [data-theme='dark'] { --color-fg: #f5f5f5; --color-bg: #0a0a0a; --color-muted: #a3a3a3; --color-border: #262626; --color-accent: #60a5fa; --color-accent-fg: #0a0a0a; } html { color: var(--color-fg); background: var(--color-bg); }

If you only invert with filter on the whole page, you will fight images, videos, and third-party embeds forever. Swap semantics.

Kill the flash: apply before paint

Persistence without a flash means the resolved theme must land before first paint. A React useEffect is too late, users see light, then dark.

Pattern that works in Vite/SPA and most SSR shells:

  1. Persist preference in localStorage (or a cookie if SSR must know).
  2. Run a tiny inline script in <head> that reads storage, resolves against matchMedia('(prefers-color-scheme: dark)'), and sets data-theme on <html>.
  3. Hydrate React with the same resolution so the toggle UI matches.
html
<!-- index.html, inline, blocking, tiny --> <script> (function () { try { var pref = localStorage.getItem('theme-preference') || 'system'; var systemDark = window.matchMedia( '(prefers-color-scheme: dark)', ).matches; var theme = pref === 'light' || pref === 'dark' ? pref : systemDark ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme-preference', pref); } catch (e) { /* private mode / blocked storage, fall through to CSS */ } })(); </script>

Pair with a CSS fallback when JS is unavailable:

css
@media (prefers-color-scheme: dark) { :root:not([data-theme]) { --color-fg: #f5f5f5; --color-bg: #0a0a0a; /* …same dark semantics… */ } }

Toggle UX and accessibility

The control is not a mystery icon with no name. Expose the three states (or light/dark if you refuse system: I do not recommend that for OS-native users).

tsx
// components/ThemeToggle.tsx, illustrative const OPTIONS = ['light', 'dark', 'system'] as const; export function ThemeToggle({ value, onChange, }: { value: (typeof OPTIONS)[number]; onChange: (next: (typeof OPTIONS)[number]) => void; }) { return ( <div role="group" aria-label="Color theme" > {OPTIONS.map((option) => ( <button key={option} type="button" aria-pressed={value === option} onClick={() => onChange(option)} > {option} </button> ))} </div> ); }

Accessibility checklist I do not skip:

  • Contrast: dark backgrounds need checked pairs, not "lighten the blue."
  • Focus rings: theme tokens should include a visible focus color on both.
  • prefers-reduced-motion: do not animate theme transitions as a parade.
  • Images: provide dark-aware assets or acceptable contrast on both themes.
  • Form controls / scrollbars: color-scheme: dark on the root helps native widgets match.
css
[data-theme='dark'] { color-scheme: dark; } [data-theme='light'] { color-scheme: light; } @media (prefers-reduced-motion: reduce) { html { transition: none !important; } }

Listen for OS changes when preference is system:

ts
const mq = window.matchMedia('(prefers-color-scheme: dark)'); mq.addEventListener('change', () => { if (readPreference() === 'system') applyResolved(); });

Failure modes

Effect-only theme application. Flash guaranteed on slow devices.

Storing resolved dark/light but labeling the UI "System." You lied; OS changes will not apply.

Only inverting backgrounds. Borders, muted text, and accent-on-accent fail quietly until a11y review.

Forcing dark for "brand cool" against user preference. Respect the control you shipped.

SSR cookie and client storage disagree. Pick one source of truth for the first paint path and document it.

Continuity

Tokens made themes possible; dark mode made them obligatory. React 19 is the follow-on for what is worth adopting mid-2025 versus what you can ignore without rewrite religion.

Takeaway

Dark mode is a resolution order, explicit preference, then system, then default, applied before paint on semantic tokens. If the toggle flashes or fights the OS, you do not have theming; you have a costume change.