Theming

Single source of truth theming with complete color inversion between light and dark modes.

Quick Start

Call getTheme() once in your root layout, then pass it down via props to child components. This provides explicit data flow and better testability.

Child Component Pattern

Why pass as props instead of calling getTheme() everywhere?

  • Explicit data flow - You see what state each component depends on
  • Better testing - Can inject mock state easily
  • Single initialization - Clear where state originates
  • Efficiency - Singleton runs once, not on every navigation

SvelteKit Execution Order

Understanding when code runs helps you choose the right pattern for state initialization. This is based on analysis of SvelteKit's internal source code.

Phase File Server Client Notes
1 +layout.server.js - Server-only data fetching
2 +layout.js Runs on every navigation
3 +layout.svelte script Runs once on component mount
4 onMount() - Client-only, after hydration

Key Insight from SvelteKit Source

+layout.js runs on every navigation, not just initial load. For singletons like theme state, this means wasted work on each page change.

+layout.svelte script runs once when the layout mounts. This is more efficient for global state that doesn't change between navigations.

When to Use Which Pattern

+layout.js

Use for data that needs server context or async fetching

  • Can use await and fetch()
  • Has access to parent() data
  • Integrates with SvelteKit's invalidation
  • Data available via $page.data

ThemeState API

Properties (Reactive)

current 'light' | 'dark' | 'system' User's theme preference
resolved 'light' | 'dark' Actual theme after resolving 'system'
dataTheme 'miozu-light' | 'miozu-dark' Value for data-theme attribute
currentTheme 'miozu-light' | 'miozu-dark' Alias for dataTheme (backwards compat)
isDark boolean True if resolved theme is dark
isLight boolean True if resolved theme is light

Methods

init() void Load from storage, setup system preference listener
sync() void Sync state with current DOM attribute (for hydration)
set(theme) void Set theme preference ('light', 'dark', or 'system')
toggle() void Toggle between light and dark (skips system)
cleanup() void Remove media query listener (rarely needed)

Static Methods

ThemeState.getThemeFromCookieString(cookies) 'miozu-light' | 'miozu-dark' Parse theme from cookie header (for SSR)

Storage

Key: miozu-theme
Values: 'light' | 'dark' | 'system'
Stored in: localStorage + cookie (for SSR)
data-theme: 'miozu-light' | 'miozu-dark'

SSR & Flash Prevention

Add this inline script to your app.html before any stylesheets:

Server-Side Rendering

Use the static method to parse theme from cookies in your +layout.server.js:

Migration Guide

⚠️ Breaking Changes in v0.4.0

If you were using the old context-based API or custom storage keys, follow this migration guide.

From Context API

From Custom Storage Keys

If your app used custom storage keys (e.g., admin-theme, theme), update your app.html to migrate:

Property Name Changes

Old Property New Property Notes
currentTheme dataTheme currentTheme still works (alias)
isInitialized Removed Use init() in onMount instead
initialize() init() Renamed for brevity
setTheme() set() Renamed for brevity

Technical Philosophy

🎯 Base16 Foundation

Miozu uses a carefully calibrated Base16 palette where each color has a specific semantic meaning. Colors 00-07 form a luminosity gradient from darkest to lightest, while 08-0F provide accent colors with consistent perceptual weight.

⚖️ Mathematical Inversion

Dark mode isn't just "dark colors" — it's a complete mathematical inversion of the luminosity scale. Base00 (background) swaps with Base07, maintaining the same relative contrast relationships.

🚀 Single Source of Truth

Call getTheme() once in your root layout, then pass it down via props. Explicit data flow, easy testing, clear ownership of state.

⚡ CSS-Only Switching

Theme changes happen via CSS attribute selectors — no JavaScript re-renders. Instant theme switching with zero layout shift.

CSS Theme Selectors