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:
- Explicit user preference (
light|dark) if stored - Else
prefers-color-scheme - Else a documented default (usually
lightfor 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:
- Persist preference in
localStorage(or a cookie if SSR must know). - Run a tiny inline script in
<head>that reads storage, resolves againstmatchMedia('(prefers-color-scheme: dark)'), and setsdata-themeon<html>. - 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: darkon 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:
tsconst 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.