User Interface

Theme Mode Resolution: Light, Dark, and System

Active preference: System (light)

Build theme mode as an architecture with preference precedence, SSR-safe bootstrapping, cross-tab synchronization, and operational telemetry.

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 | dark from media query
  • resolvedTheme: computed output applied to the document

Resolution order:

  1. explicit user preference in storage
  2. optional tenant or app default
  3. system preference
  4. 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 storage event 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

  1. ship read-only telemetry first
  2. ship preference persistence
  3. ship system listener and cross-tab sync
  4. 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.