NNicheStackTutorials
← All tutorials
SvelteKit

SvelteKit Dark Mode With Persistent Storage (No Flash)

Inline script in app.html blocks FOUC, localStorage sync, and system preference fallback — battle-tested pattern.

·12 min read·SvelteKit
SvelteKit Dark Mode With Persistent Storage (No Flash) cover

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

Tags: SvelteKitDark ModelocalStorageFOUC