General

ThemeMode

One component in the root layout and dark mode works: the choice is remembered, the system setting is followed until someone overrides it, and the right theme is on the page before the first paint rather than after it.

Setup

Render it once, in the root layout, anywhere in the markup. It draws nothing.

src/routes/+layout.svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { ThemeMode } from 'sv5ui';
  import './layout.css';

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

<ThemeMode />

{@render children()}

Tailwind needs one line to know that dark: follows a class rather than the OS setting, which is what lets a visitor override the system.

src/routes/layout.css
/* src/routes/layout.css. The variant is what makes dark: classes follow the
   .dark class ThemeMode puts on <html>. */
@import 'tailwindcss';
@import 'sv5ui/theme.css';

@custom-variant dark (&:where(.dark, .dark *));

What It Does

Three jobs, none of which a component can do from inside the page body alone:

  • Injects a small script into the document head so the class is on <html> before the first paint. Without it a dark page flashes light on every load, because the class would only arrive after hydration.
  • Remembers the choice in localStorage and keeps every open tab in step: switch in one and the others follow.
  • Watches prefers-color-scheme while the choice is system, so a laptop switching at sunset switches the page too.

Options

The defaults suit most apps. The ones worth knowing about, with the full list in the Props table at the end:

<!-- Start in dark whatever the system says, and paint the browser chrome to match -->
<ThemeMode defaultMode="dark" themeColors={{ dark: '#0b1120', light: '#ffffff' }} />

<!-- Transitions are suppressed during a switch by default. Turn that off to let
     your own color transitions run -->
<ThemeMode disableTransitions={false} />

<!-- Your own class names instead of .dark -->
<ThemeMode darkClassNames={['dark', 'theme-night']} lightClassNames={['light']} />

Reading and Changing the Mode

Six helpers come from sv5ui itself. Note the difference between the two that look alike: mode is what is on screen, while userPrefersMode is what the visitor picked and can be 'system'.

mode: userPrefersMode: system systemPrefersMode:
<script lang="ts">
  import { Button, mode, setMode, toggleMode, resetMode, userPrefersMode, systemPrefersMode } from 'sv5ui';
</script>

<p>Resolved: {mode.current}</p>            <!-- 'dark' | 'light', what is on screen -->
<p>Chosen: {userPrefersMode.current}</p>   <!-- 'dark' | 'light' | 'system' -->
<p>System: {systemPrefersMode.current}</p> <!-- what the OS reports -->

<Button label="Toggle" onclick={toggleMode} />
<Button label="Dark" onclick={() => setMode('dark')} />
<Button label="Light" onclick={() => setMode('light')} />
<Button label="Follow the system" onclick={resetMode} />

Exports

From sv5ui. The three state objects are reactive: read .current in a template or an effect and it stays up to date. On the server mode.current is undefined, so do not branch markup on it.

ExportTypeDescription
mode{ readonly current: ResolvedColorMode }The mode on screen, after the system setting is resolved. undefined while rendering on the server
userPrefersMode{ current: ColorMode }What the visitor picked. system means follow the OS. Assigning to current is the same as calling setMode
systemPrefersMode{ readonly current: ResolvedColorMode }What the OS reports right now. undefined while rendering on the server
toggleMode() => voidSwap dark and light
setMode(mode: ColorMode) => voidPick one explicitly. A value that is not a mode is ignored
resetMode() => voidGo back to following the system
createInitialModeExpression(config?: InitialModeConfig) => stringThe source of the script that sets the mode before first paint, for apps that inject it themselves

With ThemeModeButton

A ready-made toggle that calls these helpers for you. The button can flip the class on its own, but ThemeMode is what applies the saved choice when a page loads, so keep it in the layout. See ThemeModeButton.

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { ThemeMode } from 'sv5ui';
  import './layout.css';

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

<ThemeMode />

{@render children()}

Content Security Policy

The script that sets the mode before first paint is inline, so a policy without 'unsafe-inline' blocks it unless it carries a nonce. If your server builds its own policy and you have the nonce for the response, pass it on:

<!-- You already have the nonce for this response: pass it on -->
<ThemeMode nonce={data.nonce} />

The value is written into the tag as an attribute, so only the characters a CSP nonce can contain are accepted: base64 or base64url, with at most two = of padding. Anything else is dropped, the script is rendered without a nonce, and a warning in the console says so.

Nonce Values

nonceResult
abc123Accepted
YWJjMTIz+/==Accepted (base64)
a_b-cAccepted (base64url)
has spaceDropped, with a warning
x"><script>Dropped, with a warning

Injecting the Script Yourself

SvelteKit's built-in csp option generates a nonce per response and only exposes it in app.html, where a component cannot reach it. For that setup, turn the injection off and place the script yourself. createInitialModeExpression returns its source as a string.

src/app.html
<!-- src/app.html: SvelteKit fills in the nonce, your hook fills in the script -->
<head>
  <script nonce="%sveltekit.nonce%">
    %sv5ui.mode%
  </script>
  %sveltekit.head%
</head>
src/hooks.server.ts
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { createInitialModeExpression } from 'sv5ui';

// Pass the same settings you give <ThemeMode />, or the first paint and the
// component will disagree.
const modeScript = createInitialModeExpression({ defaultMode: 'system' });

export const handle: Handle = ({ event, resolve }) =>
  resolve(event, {
    transformPageChunk: ({ html }) => html.replace('%sv5ui.mode%', modeScript)
  });
src/routes/+layout.svelte
<!-- src/routes/+layout.svelte: the component keeps doing everything else -->
<ThemeMode disableHeadScriptInjection />

With script-src 'self' and nonces on, this puts the class on <html> while the document is still loading, before the body exists. The function takes the seven settings the script reads (see InitialModeConfig below) and escapes them, so a value cannot close the script tag.

Storage

The choice lives in localStorage under mode-watcher-mode, and the named theme under mode-watcher-theme. The names are kept from the package sv5ui used to wrap, so a browser that already remembers a choice keeps it. Change them with modeStorageKey and themeStorageKey.

  • A stored value that is not light, dark or system is ignored, and defaultMode is used and written back.
  • When storage is unavailable, as in a sandboxed iframe or with site data blocked, nothing throws. The mode is kept in memory: switching works, and the choice lasts until the page is reloaded.
  • Every call to setMode, toggleMode or resetMode dispatches one storage event on window in the tab that made the change, including when the value stays the same. Browsers only send that event to the other tabs, so a listener of yours that does not check event.key will now see mode changes too.
events: 0 last: nothing yet
<script lang="ts">
  let last = $state('nothing yet');

  $effect(() => {
    const onStorage = (event: StorageEvent) => {
      // Filter by key. sv5ui also dispatches this event in the tab that made the change.
      if (event.key !== 'mode-watcher-mode') return;
      last = `${event.oldValue} -> ${event.newValue}`;
    };
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  });
</script>

<p>Last mode change: {last}</p>

Migrating From mode-watcher

Swap the component and change where the helpers are imported from. The props, the helper names, the class names and the storage keys are the same.

Migration
<!-- Before: the watcher and its helpers came from a second package -->
<script lang="ts">
  import { ModeWatcher, setMode, toggleMode } from 'mode-watcher';
</script>
<ModeWatcher />

<!-- Now: the same names, from sv5ui -->
<script lang="ts">
  import { ThemeMode, setMode, toggleMode } from 'sv5ui';
</script>
<ThemeMode />

There is no rush. An app that still mounts <ModeWatcher /> keeps working: both sides read and write the same keys, so a change made through either one reaches the other, and ThemeModeButton still toggles. sv5ui logs one warning per page load to point at this section:

[sv5ui ThemeMode] A separate mode-watcher instance is running next to sv5ui. sv5ui keeps both in sync, but mount <ThemeMode /> from sv5ui instead of <ModeWatcher /> and import the mode functions from sv5ui.

mode-watcher is still installed alongside sv5ui for now, so those imports resolve. It leaves the dependencies in the next major version. The fixes described on this page apply to <ThemeMode />; an app that mounts the old watcher keeps that package's head script.

Types

Exported from sv5ui.

TypeShape
ColorMode'light' | 'dark' | 'system'
ResolvedColorMode'light' | 'dark' | undefined
ThemeColors{ light: string; dark: string } | undefined
InitialModeConfigobject
ThemeModePropsInitialModeConfig & { ... }

Props

All optional. track, disableTransitions, synchronousModeChanges, nonce and disableHeadScriptInjection belong to the component only; the other seven are also read by the head script.

PropTypeDefault
defaultMode'dark' | 'light' | 'system''system'
trackbooleantrue
themeColors{ dark: string; light: string }-
disableTransitionsbooleantrue
darkClassNamesstring[]['dark']
lightClassNamesstring[][]
defaultThemestring''
modeStorageKeystring'mode-watcher-mode'
themeStorageKeystring'mode-watcher-theme'
noncestring''
disableHeadScriptInjectionbooleanfalse
synchronousModeChangesbooleanfalse