Introduction

Not every theme system needs the full power of Context API. For most applications, a simpler approach works better: module-level reactive state with a hook-like function to access it. This pattern is used by popular libraries like svelte-themes, svelte-theme-select, and countless production applications.

The hook pattern gives you global theme state that any component can access without providers, wrappers, or prop drilling. It’s lighter, easier to test, and requires less boilerplate than Context. If you don’t need nested theme overrides (a dark card on a light page), this is the approach you should reach for first.

By the end of this article, you’ll have a complete theme system that supports light, dark, and system preference modes, persists user choices across sessions, prevents the dreaded flash of wrong theme, syncs across browser tabs, and works with SSR out of the box—all in under 100 lines of code.

When to Use Context Instead

If you need nested theme overrides—different parts of your component tree using different themes simultaneously—see Building Production-Ready Theme Systems with Context. Context’s scoping behavior makes nested providers possible, which module-level state cannot replicate.


Why Not Context?

Context solves a specific problem: passing data through the component tree without prop drilling, with the ability to override values at any level. For theming, this means you could have a dark-themed promotional section inside a light-themed page.

But most applications don’t need that. They have one global theme that applies everywhere. For this common case, Context adds unnecessary complexity. You need a provider component wrapping your app, getContext() calls that only work inside components, and extra ceremony that doesn’t provide value.

Module-level state in Svelte 5 gives you truly global reactive state. Any file can import it. Any component can read or modify it. No providers, no wrappers, no restrictions on where you can access it.

Loading diagram...

The Complete Implementation

Let’s build the entire theme system. We’ll create a single file that handles everything: state management, persistence, system preference detection, and the public API.

// src/lib/theme.svelte.ts

import { browser } from '$app/environment'

// Types
export type ThemePreference = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'

// Configuration
const STORAGE_KEY = 'theme-preference'
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year

// Module-level reactive state
let preference = $state<ThemePreference>(getStoredPreference())
let systemTheme = $state<ResolvedTheme>(getSystemTheme())

/**
 * Reads the stored theme preference from localStorage.
 * Returns 'system' if nothing is stored or if not in browser.
 */
function getStoredPreference(): ThemePreference {
	if (!browser) return 'system'

	const stored = localStorage.getItem(STORAGE_KEY)
	if (stored === 'light' || stored === 'dark' || stored === 'system') {
		return stored
	}
	return 'system'
}

/**
 * Detects the operating system's color scheme preference.
 */
function getSystemTheme(): ResolvedTheme {
	if (!browser) return 'light'
	return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

/**
 * Applies the theme to the document.
 * Sets data-theme attribute and updates CSS color-scheme.
 */
function applyTheme(theme: ResolvedTheme): void {
	if (!browser) return

	document.documentElement.dataset.theme = theme
	document.documentElement.style.colorScheme = theme
}

/**
 * Persists the preference to localStorage and sets a cookie for SSR.
 */
function persistPreference(pref: ThemePreference): void {
	if (!browser) return

	localStorage.setItem(STORAGE_KEY, pref)
	document.cookie = `${STORAGE_KEY}=${pref};path=/;max-age=${COOKIE_MAX_AGE};SameSite=Lax`
}

// Initialize system preference listener
if (browser) {
	const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

	mediaQuery.addEventListener('change', (event) => {
		systemTheme = event.matches ? 'dark' : 'light'

		// If following system, apply the new theme
		if (preference === 'system') {
			applyTheme(systemTheme)
		}
	})

	// Sync across tabs via storage event
	window.addEventListener('storage', (event) => {
		if (event.key === STORAGE_KEY && event.newValue) {
			const newPref = event.newValue as ThemePreference
			if (newPref === 'light' || newPref === 'dark' || newPref === 'system') {
				preference = newPref
				applyTheme(newPref === 'system' ? systemTheme : newPref)
			}
		}
	})
}

/**
 * The public API for theme management.
 * Call this function from any component to access theme state and actions.
 */
export function useTheme() {
	return {
		/** The user's preference: 'light', 'dark', or 'system' */
		get preference() {
			return preference
		},

		/** The actual theme being displayed: always 'light' or 'dark' */
		get resolvedTheme(): ResolvedTheme {
			return preference === 'system' ? systemTheme : preference
		},

		/** The system's current preference, regardless of user choice */
		get systemTheme() {
			return systemTheme
		},

		/** Convenience: true if currently displaying dark theme */
		get isDark() {
			return this.resolvedTheme === 'dark'
		},

		/** Convenience: true if currently displaying light theme */
		get isLight() {
			return this.resolvedTheme === 'light'
		},

		/** Convenience: true if following system preference */
		get isSystem() {
			return preference === 'system'
		},

		/** Set the theme preference */
		setTheme(newPreference: ThemePreference) {
			preference = newPreference
			persistPreference(newPreference)
			applyTheme(this.resolvedTheme)
		},

		/** Toggle between light and dark (exits system mode) */
		toggle() {
			const next = this.resolvedTheme === 'dark' ? 'light' : 'dark'
			this.setTheme(next)
		},

		/** Reset to system preference */
		reset() {
			this.setTheme('system')
		}
	}
}

That’s the entire theme system. Let’s break down the key decisions.

Module-Level State

The $state declarations at the top of the file create reactive state that lives at the module level. This state is shared across all imports—every component that calls useTheme() gets the same reactive values. When one component changes the theme, all others update automatically.

let preference = $state<ThemePreference>(getStoredPreference())
let systemTheme = $state<ResolvedTheme>(getSystemTheme())

The useTheme Pattern

The useTheme() function returns an object with getters. This is crucial for reactivity. If we returned the values directly, they’d be snapshots that don’t update. Getters ensure that every access reads the current reactive value.

// ❌ Wrong - returns snapshot
export function useTheme() {
	return {
		resolvedTheme: preference === 'system' ? systemTheme : preference
	}
}

// ✅ Correct - getter reads current value
export function useTheme() {
	return {
		get resolvedTheme() {
			return preference === 'system' ? systemTheme : preference
		}
	}
}

Cross-Tab Synchronization

The storage event listener syncs theme changes across browser tabs. When a user changes the theme in one tab, all other tabs update immediately. This happens automatically with localStorage—we just need to listen and react.


Preventing Flash of Wrong Theme

The JavaScript above runs after hydration, which means there’s a moment where the page displays with the wrong theme. Users see a jarring flash as the correct theme applies. We prevent this with an inline script that runs before the page renders.

Create a component that injects this script into the document head:

<!-- src/lib/components/Theme.svelte -->
<script lang="ts">
	const STORAGE_KEY = 'theme-preference'
</script>

<svelte:head>
	{@html `<script>
		(function() {
			try {
				const stored = localStorage.getItem('${STORAGE_KEY}');
				const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
				
				let theme;
				if (stored === 'dark' || stored === 'light') {
					theme = stored;
				} else {
					theme = systemDark ? 'dark' : 'light';
				}
				
				document.documentElement.dataset.theme = theme;
				document.documentElement.style.colorScheme = theme;
			} catch (e) {
				// localStorage might be blocked
			}
		})()
	</script>`}
</svelte:head>

This script is tiny and runs synchronously in the <head>, before any rendering occurs. By the time the browser paints pixels, the correct theme is already applied.

Why `@html` Is Safe Here

The {@html} directive normally carries XSS risks, but here we control the entire string content. The only dynamic part is STORAGE_KEY, which is a constant we define. No user input touches this script, making it safe.

Using the Theme Component

Add it to your root layout:

<!-- src/routes/+layout.svelte -->
<script>
	import Theme from '$lib/components/Theme.svelte'
	import '../app.css'

	let { children } = $props()
</script>

<Theme />

{@render children()}

The <Theme> component renders nothing visible—it only injects the flash-prevention script.


SSR with Cookies and Hooks

For full server-side rendering support, we can read the theme from a cookie and apply it during HTML generation using SvelteKit’s server hooks. This eliminates any flash even before JavaScript loads.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
	const theme = event.cookies.get('theme-preference')

	// If no valid theme cookie, let client-side handle it
	if (!theme || !['light', 'dark', 'system'].includes(theme)) {
		return resolve(event)
	}

	// For 'system', we can't know the preference on server
	// Default to light, the inline script will correct if needed
	const resolved = theme === 'system' ? 'light' : theme

	return resolve(event, {
		transformPageChunk: ({ html }) => {
			return html.replace('data-theme=""', `data-theme="${resolved}"`)
		}
	})
}

Update your app.html to include an empty data-theme attribute:

<!-- src/app.html -->
<!DOCTYPE html>
<html lang="en" data-theme="">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		%sveltekit.head%
	</head>
	<body>
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>
Cache Considerations

Using transformPageChunk means pages with different themes will have different HTML. This can complicate caching. For static sites or CDN caching, you might prefer the client-only approach with just the inline script.

The svelte-theme-select library specifically avoids transformPageChunk for this reason, handling everything client-side with the inline script approach.


Building Theme Controls

With useTheme() available, building UI controls is straightforward.

Simple Toggle Button

<!-- src/lib/components/ThemeToggle.svelte -->
<script lang="ts">
	import { useTheme } from '$lib/theme.svelte.js'

	const theme = useTheme()
</script>

<button
	type="button"
	onclick={() => theme.toggle()}
	aria-label={theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'}
	class="theme-toggle"
>
	{#if theme.isDark}
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
			<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
		</svg>
	{:else}
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
			<circle cx="12" cy="12" r="5" />
			<line x1="12" y1="1" x2="12" y2="3" />
			<line x1="12" y1="21" x2="12" y2="23" />
			<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
			<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
			<line x1="1" y1="12" x2="3" y2="12" />
			<line x1="21" y1="12" x2="23" y2="12" />
			<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
			<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
		</svg>
	{/if}
</button>

<style>
	.theme-toggle {
		display: flex;
		align-items: center;
		justify-content: center;
		width: 40px;
		height: 40px;
		padding: 0;
		border: none;
		border-radius: 8px;
		background: var(--color-surface, #f1f5f9);
		color: var(--color-foreground, #0f172a);
		cursor: pointer;
		transition:
			background-color 0.2s,
			transform 0.2s;
	}

	.theme-toggle:hover {
		background: var(--color-surface-hover, #e2e8f0);
		transform: scale(1.05);
	}

	.theme-toggle svg {
		width: 20px;
		height: 20px;
	}
</style>

Three-Way Selector

<!-- src/lib/components/ThemeSelector.svelte -->
<script lang="ts">
	import { useTheme } from '$lib/theme.svelte.js'
	import type { ThemePreference } from '$lib/theme.svelte.js'

	const theme = useTheme()

	const options: { value: ThemePreference; label: string; icon: string }[] = [
		{ value: 'light', label: 'Light', icon: '☀️' },
		{ value: 'dark', label: 'Dark', icon: '🌙' },
		{ value: 'system', label: 'System', icon: '💻' }
	]
</script>

<div class="theme-selector" role="radiogroup" aria-label="Theme selection">
	{#each options as option (option.value)}
		<button
			type="button"
			role="radio"
			aria-checked={theme.preference === option.value}
			class="option"
			class:selected={theme.preference === option.value}
			onclick={() => theme.setTheme(option.value)}
		>
			<span class="icon">{option.icon}</span>
			<span class="label">{option.label}</span>
		</button>
	{/each}
</div>

{#if theme.isSystem}
	<p class="system-note">
		Currently using <strong>{theme.resolvedTheme}</strong> based on system settings.
	</p>
{/if}

<style>
	.theme-selector {
		display: inline-flex;
		gap: 4px;
		padding: 4px;
		border-radius: 12px;
		background: var(--color-surface, #f1f5f9);
	}

	.option {
		display: flex;
		align-items: center;
		gap: 6px;
		padding: 8px 12px;
		border: none;
		border-radius: 8px;
		background: transparent;
		color: var(--color-foreground, #0f172a);
		font-size: 14px;
		cursor: pointer;
		transition: background-color 0.2s;
	}

	.option:hover:not(.selected) {
		background: var(--color-surface-hover, #e2e8f0);
	}

	.option.selected {
		background: var(--color-background, #ffffff);
		box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
	}

	.icon {
		font-size: 16px;
	}

	.system-note {
		margin-top: 8px;
		font-size: 12px;
		color: var(--color-foreground-muted, #64748b);
	}
</style>

For applications with many themes (like daisyUI’s 35+ themes):

<!-- src/lib/components/ThemeDropdown.svelte -->
<script lang="ts">
	import { useTheme } from '$lib/theme.svelte.js'
	import type { ThemePreference } from '$lib/theme.svelte.js'

	const theme = useTheme()

	const themes: ThemePreference[] = ['light', 'dark', 'system']

	function handleChange(event: Event) {
		const select = event.target as HTMLSelectElement
		theme.setTheme(select.value as ThemePreference)
	}
</script>

<select
	value={theme.preference}
	onchange={handleChange}
	class="theme-dropdown"
	aria-label="Select theme"
>
	<option value="" disabled>Choose a theme</option>
	{#each themes as t (t)}
		<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
	{/each}
</select>

<style>
	.theme-dropdown {
		padding: 8px 12px;
		border: 1px solid var(--color-border, #e2e8f0);
		border-radius: 8px;
		background: var(--color-surface, #f8fafc);
		color: var(--color-foreground, #0f172a);
		font-size: 14px;
		cursor: pointer;
	}
</style>

CSS Setup

Your global CSS needs to define the theme variables:

/* src/app.css */

:root {
	/* Light theme (default) */
	--color-background: #ffffff;
	--color-surface: #f8fafc;
	--color-surface-hover: #f1f5f9;
	--color-foreground: #0f172a;
	--color-foreground-muted: #64748b;
	--color-primary: #3b82f6;
	--color-border: #e2e8f0;
}

:root[data-theme='dark'] {
	--color-background: #0f172a;
	--color-surface: #1e293b;
	--color-surface-hover: #334155;
	--color-foreground: #f8fafc;
	--color-foreground-muted: #94a3b8;
	--color-primary: #60a5fa;
	--color-border: #334155;
}

html,
body {
	margin: 0;
	background-color: var(--color-background);
	color: var(--color-foreground);
	transition:
		background-color 0.3s,
		color 0.3s;
}

Using Theme Outside Components

One advantage of the hook pattern over Context: you can use it anywhere, not just in components.

// src/lib/chart-config.ts
import { useTheme } from '$lib/theme.svelte.js'

export function getChartColors() {
	const theme = useTheme()

	return {
		background: theme.isDark ? '#1e293b' : '#ffffff',
		gridLines: theme.isDark ? '#334155' : '#e2e8f0',
		text: theme.isDark ? '#f8fafc' : '#0f172a',
		primary: theme.isDark ? '#60a5fa' : '#3b82f6'
	}
}
// src/lib/api.ts
import { useTheme } from '$lib/theme.svelte.js'

export function getPreferredImageVariant(baseUrl: string) {
	const theme = useTheme()
	return `${baseUrl}?variant=${theme.resolvedTheme}`
}

Complete Project Structure

src/
├── lib/
│   ├── theme.svelte.ts          # Core theme logic
│   └── components/
│       ├── Theme.svelte         # Flash prevention script
│       ├── ThemeToggle.svelte   # Simple toggle button
│       ├── ThemeSelector.svelte # Three-way selector
│       └── ThemeDropdown.svelte # Dropdown select
├── routes/
│   └── +layout.svelte           # Root layout with Theme component
├── hooks.server.ts              # Optional SSR cookie handling
├── app.html                     # HTML template
└── app.css                      # Theme CSS variables

Common Mistakes

Watch Out For These

Returning values instead of getters:

// ❌ Wrong - loses reactivity
export function useTheme() {
	return { isDark: preference === 'dark' }
}

// ✅ Correct - maintains reactivity
export function useTheme() {
	return {
		get isDark() {
			return preference === 'dark'
		}
	}
}

Forgetting the Theme component:

Without the <Theme> component’s inline script, users see a flash of wrong theme on every page load. Always include it in your root layout.

Not handling localStorage errors:

In private browsing or with strict privacy settings, localStorage might throw. Always wrap in try/catch:

function getStoredPreference(): ThemePreference {
	if (!browser) return 'system'
	try {
		return (localStorage.getItem(STORAGE_KEY) as ThemePreference) || 'system'
	} catch {
		return 'system'
	}
}

Performance Considerations

The hook pattern is extremely lightweight. There’s no provider tree, no context lookups, just direct access to module-level state. The entire implementation is under 100 lines and adds minimal bundle size.

The inline script for flash prevention is about 300 bytes minified. It runs synchronously but executes in microseconds—just reading localStorage and setting a data attribute.

CSS custom properties are the performance champion here. When you change --color-background on the document root, the browser handles all updates internally. No JavaScript loops, no DOM manipulation, just native browser optimization.


When to Use Context Instead

The hook pattern covers most use cases, but Context has one capability it lacks: nested scopes. If you need a dark promotional card on a light page, or a light code editor in a dark app, those require different parts of your tree to have different theme values simultaneously.

Context’s provider pattern enables this:

<!-- Page is light, but this card is dark -->
<ThemeProvider forceTheme="dark">
	<PromoCard />
</ThemeProvider>

Components inside the nested provider get the forced dark theme, while everything else uses the page theme. Module-level state can’t do this—it’s global by nature.

For a full implementation of Context-based theming with nested overrides, see Building Production-Ready Theme Systems with Context.


Conclusion

The useTheme() hook pattern offers the simplest path to theme management in Svelte 5. Module-level reactive state gives you global access without providers. The inline script prevents flash. Cross-tab synchronization comes free with localStorage events. And the whole thing fits in under 100 lines of code.

For the vast majority of applications—where one theme applies globally—this is the right approach. Reach for Context only when you specifically need nested theme overrides, and even then, consider whether that complexity is worth it.

The best code is often the code you don’t write. Start simple, and add complexity only when you have a concrete need for it.


Key Takeaways

  1. Module-level $state creates globally shared reactive state that any file can import.

  2. Return getters, not values from your hook function to maintain reactivity.

  3. Inline script in <svelte:head> prevents flash by applying theme before render.

  4. Storage events provide free cross-tab synchronization.

  5. Cookies enable SSR but consider cache implications of transformPageChunk.

  6. CSS custom properties are the most performant way to apply theme colors.

  7. Context is only needed for nested theme overrides—start with the hook pattern.


Further Reading

Libraries Using This Pattern

Official Documentation