Theme Mode Resolution Playbook
Scope
This guide defines a production strategy for light, dark, and system mode behavior across SSR, hydration, client navigation, and multi-tab usage.
Design Goals
- No flash between initial paint and hydration.
- Deterministic precedence across explicit user preference, org defaults, and system preference.
- Stable behavior across tabs and browser restarts.
- Platform parity across macOS, Linux, and Windows.
Decision Model
Define three distinct states:
- preference:
light | dark | system - systemTheme:
light | darkfrom media query - resolvedTheme: computed output applied to the document
Resolution order:
- explicit user preference in storage
- optional tenant or app default
- system preference
- fallback to light
SSR and First Paint Strategy
Use a tiny inline script in app shell to set data-theme before app JS executes.
<script>
(() => {
try {
const pref = localStorage.getItem("theme-preference");
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const resolved = pref === "light" || pref === "dark"
? pref
: (systemDark ? "dark" : "light");
document.documentElement.setAttribute("data-theme", resolved);
} catch (_) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>Store Topology
Use one writable store for preference, one for system state, and one derived store for resolved output.
type ThemePreference = "light" | "dark" | "system";
type ResolvedTheme = "light" | "dark";
export const themePreference = writable<ThemePreference>("system");
export const systemTheme = writable<ResolvedTheme>("light");
export const resolvedTheme = derived(
[themePreference, systemTheme],
([$pref, $sys]) => ($pref === "system" ? $sys : $pref)
);Event Wiring
- subscribe to media query change events
- subscribe to
storageevent for cross-tab updates - apply resolved theme to
documentElement.dataset.theme - persist preference only when it changes
Failure Modes and Guards
- localStorage unavailable: default to system->light fallback
- stale values in storage: ignore and normalize
- hydration mismatch: always set pre-hydration data-theme
- race conditions on startup: compute once, apply once, then subscribe
Test Matrix
Unit tests:
- resolution precedence for all permutations
- invalid storage value normalization
- derived store transitions
Integration tests:
- startup with no preference follows system
- toggling preference persists and applies
- changing OS theme updates app in system mode
E2E tests:
- no flash on first paint in both themes
- cross-tab sync after preference change
Telemetry
Record:
- theme_preference_changed
- theme_resolved
- theme_source_used (user, tenant, system, fallback)
Rollout Plan
- ship read-only telemetry first
- ship preference persistence
- ship system listener and cross-tab sync
- monitor mismatch rate and regressions
Anti-patterns
- using one boolean for dark mode and no system state
- applying class after hydration only
- coupling icon rendering directly to raw storage value
Code Examples
Svelte
<script lang="ts">
import { derived, writable } from 'svelte/store';
type ThemePreference = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
export const themePreference = writable<ThemePreference>('system');
export const systemTheme = writable<ResolvedTheme>('light');
export const resolvedTheme = derived(
[themePreference, systemTheme],
([$themePreference, $systemTheme]) => ($themePreference === 'system' ? $systemTheme : $themePreference)
);
</script>Theme menu selections should write the preference store, while the quick-toggle button should set the next explicit mode so the control always previews the result.