This guide demonstrates how to create a robust theme switching system for SvelteKit applications. We’ll build a global context store that persists user preferences via cookies, ensuring themes load correctly on the server and preventing flash of unstyled content (FOUC). The implementation includes type-safe cookie management, server-side rendering support, and a complete theme switcher component using DaisyUI.
Dependencies
This implementation requires two key packages for cookie management and data validation:
js-cookie: Provides a simple API for handling cookies in the browser
valibot: Ensures type safety and validates cookie data structure
bash
pnpm add js-cookie valibot
Why cookies over localStorage? Cookies are accessible on both client and server, making them essential for SSR applications. This prevents FOUC by allowing the server to render the correct theme immediately, rather than waiting for client-side JavaScript to load and apply the theme from localStorage.
Theme Definitions
Define your available themes using DaisyUI’s CSS plugin system. This approach allows you to customize existing themes or create entirely new ones with your brand colors and styling preferences.
name: The theme identifier used in your application
default: Whether this is the default theme (only one should be true)
prefersdark: Indicates if this theme should be used when system prefers dark mode
color-scheme: Tells the browser whether this is a light or dark theme
Custom CSS variables: Override default DaisyUI colors with your brand palette
Cookie Management Utilities
These utility functions provide a unified, type-safe API for managing cookies across both client and server environments in SvelteKit. They handle serialization, validation, and provide seamless integration with your app’s state management.
ts
import { browser } from "$app/environment";import { getRequestEvent } from "$app/server";import Cookie from "js-cookie";import * as v from "valibot";/** * Set a cookie from the browser using `js-cookie`. * This function only works in the browser environment and automatically * serializes complex objects to JSON. * * @param name Name of the cookie * @param schema Valibot schema for type validation * @param value Value to store in the cookie * @param expires Expiration time in milliseconds (default: 1 year) */export function setCookie<TSchema extends v.BaseSchema<any, any, any>>( name: string, schema: TSchema, value: v.InferInput<TSchema>, expires = 1000 * 60 * 60 * 24 * 365) { if (!browser) return value; if (typeof value === "undefined") throw new Error("Value is undefined"); const parsed = v.parse(schema, value); Cookie.set(name, typeof parsed !== "string" ? JSON.stringify(parsed) : parsed, { path: "/", expires: new Date(Date.now() + expires) }); return value;}/** * Get a cookie value from the server during SSR. * Falls back to setting a default value if the cookie doesn't exist * or contains invalid data. * * @param name Name of the cookie to retrieve * @param schema Valibot schema for validation and default values * @returns Parsed and validated cookie value */export function serverGetCookie<TSchema extends v.BaseSchema<any, any, any>>(name: string, schema: TSchema) { try { const event = getRequestEvent(); if (!event) throw new Error("No event"); const val = event.cookies.get(name); const cookie = val && val !== "undefined" ? JSON.parse(val) : serverSetCookie(name, schema, undefined); return v.parse(schema, cookie); } catch (err) { console.error(err); return serverSetCookie(name, schema, undefined); }}/** * Set a cookie from the server during SSR. * Supports both regular and HTTP-only cookies for enhanced security. * * @param name Name of the cookie * @param schema Valibot schema for validation * @param value Value to store * @param options Cookie configuration options * @param options.expires Expiration time in milliseconds * @param options.httpOnly Whether cookie should be HTTP-only (inaccessible to JavaScript) * @returns Parsed and validated cookie value */export function serverSetCookie<TSchema extends v.BaseSchema<any, any, any>>( name: string, schema: TSchema, value: v.InferInput<TSchema>, options?: { expires?: number; httpOnly?: boolean; }) { const opts = { expires: 1000 * 60 * 60 * 24 * 365, httpOnly: false, ...options }; const event = getRequestEvent(); if (!event) throw new Error("No event"); const parsed = v.parse(schema, value); event.cookies.set(name, JSON.stringify(parsed), { path: "/", expires: new Date(Date.now() + opts.expires), httpOnly: opts.httpOnly }); return parsed;}
Key Features:
Type Safety: Uses Valibot schemas to ensure data integrity
Universal API: Works seamlessly on both client and server
Error Handling: Gracefully handles malformed or missing cookies
Automatic Serialization: Handles JSON serialization for complex objects
Security Options: Supports HTTP-only cookies for sensitive data
Schema and Constants Definition
Define your application’s data structure and available themes using TypeScript and Valibot for complete type safety. The following schema showcases the theme selector options, but is extensible for other user preferences as needed.
ts
import * as v from "valibot";import { themes, themeGroups } from "$lib/constants";/** * Main application cookie schema that stores user preferences. * Uses optional fields with sensible defaults to handle partial data gracefully. */export type AppCookie = v.InferOutput<typeof appCookieSchema>;export const appCookieSchema = v.optional( v.object({ settings: v.optional( v.object({ // Theme can be any defined theme or "system" for auto-detection theme: v.optional(v.picklist(themes.map((t) => t.value)), "system"), // Mode tracks the resolved light/dark preference mode: v.optional(v.picklist(themeGroups), "dark") }), {} ) }), {});// Default values parsed through the schema for consistencyexport const appDefaults = v.parse(appCookieSchema, {});
ts
/** * Theme configuration with metadata for grouping and system preference detection */type Theme = { name: string; // Display name for UI value: string; // CSS theme identifier group?: (typeof themeGroups)[number]; // Light or dark classification};// Core theme categories for system preference detectionexport const themeGroups = ["dark", "light"] as const;/** * Complete theme definitions including system theme for auto-detection. * Themes are grouped by light/dark for easier organization in UI components. */export const themes = [ { name: "System", // Automatically detects user's OS preference value: "system" }, // Light themes { name: "Light", value: "light", group: "light" }, { name: "Retro", value: "retro", group: "light" }, { name: "Valentine", value: "valentine", group: "light" }, { name: "Garden", value: "garden", group: "light" }, // Dark themes { name: "Dark", value: "dark", group: "dark" }, { name: "Black", value: "black", group: "dark" }, { name: "Halloween", value: "halloween", group: "dark" }, { name: "Night", value: "night", group: "dark" }] as const satisfies Theme[];// Type exports for use throughout the applicationexport type Themes = (typeof themes)[number]["value"];export type ThemeGroups = (typeof themeGroups)[number];
Global Context Store
The global context store provides centralized state management for your application settings. Built with Svelte 5’s runes system, it automatically persists changes to cookies and provides reactive updates throughout your app.
ts
import { getContext, setContext } from "svelte";import { setCookie } from "$server/cookie";import { appCookieSchema, appDefaults, type AppCookie } from "$lib/schemas";/** * Global application state store using Svelte 5 runes. * Automatically persists changes to cookies and provides reactive updates. */class Global { _app: AppCookie = $state(appDefaults); constructor(app: AppCookie) { this._app = app; // Automatically sync state changes to cookies $effect(() => { setCookie("app", appCookieSchema, this._app); }); } get app() { return this._app; } set app(value: AppCookie) { this._app = value; }}const globalKey = Symbol();/** * Retrieve the global context store instance. * Must be called within a component that has access to the context. */export function getGlobal() { return getContext<Global>(globalKey);}/** * Create and register the global context store. * Should be called once in your root layout component. */export function createGlobal(app: AppCookie) { const global = new Global(app); return setContext(globalKey, global);}
Key Features:
Reactive State: Uses Svelte 5’s $state rune for fine-grained reactivity
Automatic Persistence: Changes are automatically saved to cookies via $effect
Type Safety: Full TypeScript support with proper type inference
Context-Based: Available throughout your component tree without prop drilling
Setting Up the Global Store
Initialize the global context store in your application’s root layout by loading the cookie data server-side and creating the store instance.
ts
import { serverGetCookie } from "$server/cookie.js";import { appCookieSchema } from "$lib/schemas";export const load = async (event) => { // Retrieve existing cookie or create with default values const app = serverGetCookie("app", appCookieSchema); return { app };};
svelte
<script lang="ts"> import { createGlobal } from "$lib/stores.svelte"; const { data } = $props(); createGlobal(data.app);</script><!-- Your app content goes here --><slot />
Theme Switcher Component
The theme switcher component demonstrates how to use the global store to create a reactive UI that responds to both user selections and system preferences. It includes automatic mode detection and smooth theme transitions.
svelte
<script lang="ts"> import { themeGroups, themes } from "$lib/constants"; import { getGlobal } from "$lib/stores.svelte"; import { MediaQuery } from "svelte/reactivity"; // Access the global context store const global = getGlobal(); // Local reactive state for the theme selector let theme = $state(global.app.settings.theme); // Monitor system dark mode preference const mq = new MediaQuery("(prefers-color-scheme: dark)"); /** * Compute the effective mode (light/dark) based on: * 1. If "system" is selected, use the system preference * 2. Otherwise, use the theme's defined group * 3. Fall back to the stored mode preference */ const mode = $derived.by(() => { const selected = themes.find((t) => t.value === theme); if (selected) { if (selected.value === "system") { return mq.current ? "dark" : "light"; } else { return selected.group; } } return global.app.settings.mode; }); /** * Apply theme changes to both the global store and the DOM. * This effect runs whenever theme or mode changes. */ $effect(() => { if (theme !== global.app.settings.theme || mode !== global.app.settings.mode) { // Update the global store (which triggers cookie persistence) global.app.settings.theme = theme; global.app.settings.mode = mode; // Apply changes to the DOM for immediate visual feedback const opposite = mode === "dark" ? "light" : "dark"; document.documentElement.classList.replace(opposite, mode); document.documentElement.dataset.theme = theme; } });</script><!-- Theme selector dropdown with grouped options --><select class="select select-bordered select-sm flex-1 leading-4" bind:value={theme}> <option value="system" selected={global.app.settings.theme === "system"}>System</option> {#each themeGroups as group} <hr /> {#each themes.filter((t) => "group" in t && t.group === group) as theme} <option value={theme.value} selected={global.app.settings.theme === theme.value}> {theme.name} </option> {/each} {/each}</select>
Component Features:
System Detection: Automatically detects and responds to OS dark mode changes
Immediate Updates: Applies theme changes to the DOM instantly for smooth UX
Grouped Options: Organizes themes by light/dark categories in the dropdown
Reactive State: Automatically syncs with the global store and persists to cookies
Preventing FOUC with Server-Side Theme Preloading
To eliminate flash of unstyled content, we need to apply the correct theme classes to the HTML document before any JavaScript runs. This is achieved by modifying the HTML template during server-side rendering.
import { type Handle } from "@sveltejs/kit";import { serverGetCookie } from "$server/cookie.js";import { appCookieSchema } from "$lib/schemas";/** * Server hook that preloads theme information into the HTML document. * This prevents FOUC by ensuring the correct theme is applied before * any client-side JavaScript executes. */const preloadTheme: Handle = async ({ event, resolve }) => { // Load user's theme preferences from cookies const app = serverGetCookie("app", appCookieSchema); const mode = app.settings.mode; // Use specific theme for app routes, fallback to mode for others const theme = event.route.id?.startsWith("/(app)") ? app.settings.theme : app.settings.mode; return await resolve(event, { transformPageChunk: ({ html }) => { // Replace the %theme% placeholder with actual theme classes return html.replace(/%theme%/g, `class="${mode}" data-theme="${theme}"`); } });};export const handle = sequence(preloadTheme);
How It Works:
Server-Side Detection: The hook reads the user’s theme preference from cookies during SSR
HTML Transformation: Replaces the %theme% placeholder with the appropriate CSS classes
Immediate Application: The correct theme is applied before any JavaScript loads
Route-Specific Logic: Can apply different themes based on the current route
This approach ensures that users see the correct theme immediately, without any flash or delay, providing a seamless user experience across all devices and connection speeds.