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.
mode-watcher. The props, the six mode helpers and the localStorage keys are unchanged, so nothing needs editing and nobody loses a saved choice. New in the same release: createInitialModeExpression, for apps that inject the head script themselves under a strict CSP, and the ColorMode, ResolvedColorMode, ThemeColors and InitialModeConfig types.Setup
Render it once, in the root layout, anywhere in the markup. It draws nothing.
<!-- 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. 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
localStorageand keeps every open tab in step: switch in one and the others follow. - Watches
prefers-color-schemewhile 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'.
<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.
| Export | Type | Description |
|---|---|---|
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 | () => void | Swap dark and light |
setMode | (mode: ColorMode) => void | Pick one explicitly. A value that is not a mode is ignored |
resetMode | () => void | Go back to following the system |
createInitialModeExpression | (config?: InitialModeConfig) => string | The 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
| nonce | Result |
|---|---|
abc123 | Accepted |
YWJjMTIz+/== | Accepted (base64) |
a_b-c | Accepted (base64url) |
has space | Dropped, 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: 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
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: 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.
defaultMode, darkClassNames or a storage key to one, pass the same value to the other. 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,darkorsystemis ignored, anddefaultModeis 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,toggleModeorresetModedispatches onestorageevent onwindowin 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 checkevent.keywill now see mode changes too.
<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.
<!-- 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.
| Type | Shape |
|---|---|
ColorMode | 'light' | 'dark' | 'system' |
ResolvedColorMode | 'light' | 'dark' | undefined |
ThemeColors | { light: string; dark: string } | undefined |
InitialModeConfig | object |
ThemeModeProps | InitialModeConfig & { ... } |
Props
All optional. track, disableTransitions, synchronousModeChanges, nonce and disableHeadScriptInjection belong to the component only; the other seven are also read by the head script.
| Prop | Type | Default |
|---|---|---|
defaultMode | 'dark' | 'light' | 'system' | 'system' |
track | boolean | true |
themeColors | { dark: string; light: string } | - |
disableTransitions | boolean | true |
darkClassNames | string[] | ['dark'] |
lightClassNames | string[] | [] |
defaultTheme | string | '' |
modeStorageKey | string | 'mode-watcher-mode' |
themeStorageKey | string | 'mode-watcher-theme' |
nonce | string | '' |
disableHeadScriptInjection | boolean | false |
synchronousModeChanges | boolean | false |