SvelteKit Dark Mode With Persistent Storage (No Flash)
Inline script in app.html blocks FOUC, localStorage sync, and system preference fallback — battle-tested pattern.
Tested on: SvelteKit 2.16, Chrome 136, Safari 18 Problem: Toggle works, but refresh flashes white for 200ms. Lighthouse CLS spike.
Why onMount is too late
<script>
import { onMount } from 'svelte';
onMount(() => {
if (localStorage.theme === 'dark') document.documentElement.classList.add('dark');
});
</script>
Browser paints light theme first, then JS runs. Users see a flash.
Fix part 1 — blocking inline script in app.html
src/app.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<script>
(function () {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = stored === 'dark' || (!stored && prefersDark);
if (dark) document.documentElement.classList.add('dark');
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
This runs before first paint. No framework needed.
Fix part 2 — Svelte store for toggle
src/lib/stores/theme.ts:
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
function createTheme() {
const { subscribe, set } = writable<'light' | 'dark'>('light');
if (browser) {
const isDark = document.documentElement.classList.contains('dark');
set(isDark ? 'dark' : 'light');
}
return {
subscribe,
toggle() {
const next = document.documentElement.classList.toggle('dark') ? 'dark' : 'light';
localStorage.setItem('theme', next);
set(next);
},
set(mode: 'light' | 'dark') {
document.documentElement.classList.toggle('dark', mode === 'dark');
localStorage.setItem('theme', mode);
set(mode);
}
};
}
export const theme = createTheme();
Toggle component
<script lang="ts">
import { theme } from '$lib/stores/theme';
</script>
<button onclick={() => theme.toggle()} aria-label="Toggle dark mode">
{#if $theme === 'dark'}☀️{:else}🌙{/if}
</button>
CSS (Tailwind or plain)
:root { --bg: #fafafa; --text: #111; }
:root.dark { --bg: #0a0a0a; --text: #ededed; }
body { background: var(--bg); color: var(--text); }
With Tailwind: darkMode: 'class' in tailwind.config.js.
Error — hydration mismatch
Hydration failed because the initial UI does not match what was rendered on the server
Server doesn't know localStorage. Don't render theme-dependent markup on server:
{#if browser}
<span>{$theme} mode</span>
{/if}
Or use class:dark only on <html> via the inline script (no server mismatch).
SSR + static export note
With prerender = true, server HTML is always light-class. Inline script fixes it before paint. This is intentional.
Result
FOUC: 200ms white flash → 0ms
CLS from theme swap: 0.08 → 0.00
localStorage key: 'theme' ('light' | 'dark')
System preference respected when no stored value