Dark Mode Implementation Patterns
Token-based theming, flash prevention, and system preference detection for robust dark mode.
Why
Users expect dark mode — 30%+ enable it system-wide. Poor implementations flash white on load, ignore OS preference, or hardcode colors that break branded themes.
A token-driven approach with no-flash hydration is table stakes.
When
Implement dark mode for any consumer-facing app, documentation site, or tool used at night. Skip for print-only or embedded kiosk UIs with fixed lighting.
How
Example
Common Problems
- ⚠FOUC (flash of unstyled content) — theme applied after hydration, users see white flash on refresh
- ⚠Hardcoded dark colors — .dark .btn { background: #1e293b } instead of token, breaks white-label
- ⚠Forgotten surfaces — bg token swapped but modals/inputs keep light surface, looks broken
- ⚠Logos invisible — black logo on dark bg, no <picture> variant provided
Best Practices
- ✓Inline the theme script in <head> before CSS — zero flash, no hydration dependency
- ✓Use 3 modes: light / dark / system — most apps force binary and ignore OS preference
- ✓Audit every surface in dark mode — use Storybook dark addon, not manual toggling
- ✓Provide dark-optimized logos via <picture><source media='(prefers-color-scheme: dark)'>
FAQ
How do I prevent the white flash on page load?
Inline a script in <head> before CSS that reads localStorage and matchMedia, then sets data-theme on <html>. The theme is applied before first paint, so no flash.
Should I support light/dark or also 'system'?
Three modes: light, dark, system. 'System' follows prefers-color-scheme and updates when the OS changes. Most apps force binary and ignore OS preference, which users dislike.
Why are my logos invisible in dark mode?
Black logos on dark backgrounds disappear. Provide a dark-optimized variant via <picture><source media='(prefers-color-scheme: dark)'> or invert with CSS filter.
How do I theme without hardcoding dark colors per component?
Define paired tokens (--bg, --text, --surface) and override them at :root[data-theme='dark']. Components reference var(--bg) only — never component-specific dark overrides.
How do I audit every surface in dark mode?
Use the Storybook dark-mode addon to toggle all stories, and screenshot both themes in Playwright CI. Don't manually click through — you'll miss modals and inputs.