@plcharriere/svelte-themes
v0.3.0
Published
SSR-safe theme switching for SvelteKit 2 + Svelte 5. Plain CSS themes, cookie-persisted, prefers-color-scheme aware, with live cross-tab sync.
Downloads
96
Maintainers
Readme
svelte-themes
SSR-safe, cookie-persisted theme switching for SvelteKit 2 + Svelte 5. Themes are plain CSS files, lazy-loaded on demand. Compose multiple orthogonal axes (palette × radius × density, all active at once), with light/dark as an independent toggle that respects prefers-color-scheme until the user explicitly chooses. Optional scoped themes let / and /admin carry their own theme choices side-by-side. Cross-tab sync, no flash on reload.
setTheme('bubblegum'); // switch theme, persist via cookie
setScheme('dark'); // scheme is independent of theme
setScheme('system'); // follow OS prefers-color-scheme
toggleScheme(); // flip between 'light' and 'dark'
setTheme('candyland', 'dark'); // switch theme + scheme in one callNo flash on reload. The active theme's CSS is read from a cookie server-side and inlined into the HTML before the page ships.
Upgrading from 0.2.x?
app.htmlchanged (breaking) and scoped config is now self-contained per scope. See Migrating from 0.2.x.
Install
npm install @plcharriere/svelte-themespnpm add @plcharriere/svelte-themesyarn add @plcharriere/svelte-themesbun add @plcharriere/svelte-themesQuickstart
The 95% path: one set of themes for the whole app, one theme active at a time. Reach for multi-axis themes when you want several dimensions composing at once, or scoped themes when different sections of your site need independent choices.
1. Write your themes as plain CSS
A theme is a CSS file that defines variables on :root (and optionally on .dark for the dark variant). Drop them anywhere in your project — the convention is src/themes/.
/* src/themes/bubblegum.css */
:root {
--background: oklch(0.94 0.02 345);
--foreground: oklch(0.47 0 0);
--primary: oklch(0.62 0.18 348);
/* ... */
}
.dark {
--background: oklch(0.25 0.03 234);
--foreground: oklch(0.93 0.02 349);
--primary: oklch(0.92 0.08 87);
/* ... */
}The library only swaps the file's content into a <style> element — it's CSS-framework-agnostic and never parses what's inside.
Using Tailwind CSS v4? Theme files are compiled and injected separately from your main stylesheet, which has a few important consequences for
@apply,@theme, and cascade layers. Read Using with Tailwind CSS v4 before writing your theme files — it'll save you a confusing afternoon.
2. Register your themes
// src/themes.ts
import { createThemes } from '@plcharriere/svelte-themes';
export const {
setTheme,
getThemes,
getCurrentTheme,
getDefaultTheme,
getThemeSource,
isLoadingTheme,
getLoadingTheme
} = createThemes({
themes: {
bubblegum: () => import('./themes/bubblegum.css?inline'),
candyland: () => import('./themes/candyland.css?inline')
},
defaultTheme: 'bubblegum',
defaultScheme: 'system'
});createThemes is generic over the themes record, so the returned setTheme(name), getCurrentTheme(), etc. are typed against your specific theme names — setTheme('blubgegum') is a TS error, autocomplete works, switch statements narrow correctly.
The scheme functions — setScheme, toggleScheme, isDark, getScheme, getDefaultScheme, getSchemeSource — don't narrow against your theme names, so they're imported directly from @plcharriere/svelte-themes (see Switch themes). Everything else lives on the handle.
Each theme is a dynamic import. Vite emits one chunk per theme — only the active one is loaded on the server, only the one being switched to is fetched on the client.
Alternative — untyped pattern. If you don't care about theme-name narrowing, you can discard the return value and import the loosely-typed theme functions from the package root:
// src/themes.ts
import { createThemes } from '@plcharriere/svelte-themes';
createThemes({ themes: { /* ... */ } });// anywhere
import { setTheme, getCurrentTheme } from '@plcharriere/svelte-themes';
setTheme('bubblegum'); // name: string — no autocomplete, no typo checkSame runtime behavior, same reactive state — you just lose keyof T narrowing on the theme functions.
3. Wire SvelteKit
// src/hooks.server.ts
import './themes';
import { createThemesHandle } from '@plcharriere/svelte-themes/server';
export const handle = createThemesHandle();<!-- src/routes/+layout.svelte -->
<script>
import '../themes'; // ensures createThemes() runs on the client
let { children } = $props();
</script>
{@render children()}<!-- src/app.html -->
<!doctype html>
<html lang="en" class="%dark%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>app.html carries exactly one placeholder: %dark% (dark or empty) on <html>. Everything else is injected by the handle on every request — it inserts the active theme's <style> element(s) and a tiny boot script before </head>. The boot script fixes up the dark class from prefers-color-scheme whenever the user is in system mode (cookie scheme=system, or no cookie + defaultScheme: 'system') — it runs before paint, so no flash.
There is no
data-themeattribute and no<style id="svelte-themes">%theme-css%</style>line anymore — the handle injects the theme<style>directly. If you're upgrading, delete both. See Migrating from 0.2.x.
4. Switch themes
<script>
import { getThemes, getCurrentTheme, setTheme } from '../themes';
import { isDark, getScheme, setScheme, toggleScheme } from '@plcharriere/svelte-themes';
</script>
<select onchange={(e) => setTheme(e.currentTarget.value)} value={getCurrentTheme()}>
{#each getThemes() as name}
<option value={name}>{name}</option>
{/each}
</select>
<button onclick={toggleScheme}>
Toggle <span class="dark:hidden">dark</span><span class="hidden dark:inline">light</span>
</button>
<button onclick={() => setScheme('system')}>System</button>
<!-- 3-way Light / Dark / System select -->
<select onchange={(e) => setScheme(e.currentTarget.value)} value={getScheme()}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>setTheme and setScheme write the configured cookies (defaults theme and scheme) so the choice survives reloads. setScheme('system') writes scheme=system and applies prefers-color-scheme. The active theme's CSS swaps in instantly via the <style> element the server already rendered. The getCurrentTheme() / isDark() / getScheme() reads above are reactive — when another tab broadcasts a change, the select's value and the dark/light label flip without any extra wiring.
Theme axis vs scheme axis
The library treats theme (the named palette) and scheme (light/dark) as two orthogonal axes. Scheme is not one of the multi-axis themes below — it has its own light/dark semantics and never touches a theme <style>.
- Theme — a name like
'bubblegum'or'ocean'resolving to a CSS file. Set withsetTheme. Persisted in thethemecookie. - Scheme —
'light' | 'dark' | 'system'. Set withsetScheme/toggleScheme. Persisted in theschemecookie. Renders as thedarkclass on<html>.
Combine freely: bubblegum light, bubblegum dark, ocean light, ocean dark.
For each axis, accessors give you the current value, default, and source:
Theme
getCurrentTheme()→string— the active theme name.getDefaultTheme()→string— the configured default. Useful for a "Reset to default" button without hardcoding the name.getThemeSource()→'cookie' | 'default'—'cookie'if the user explicitly picked a theme,'default'if no cookie is set anddefaultThemeis being used.
Scheme
isDark()→boolean— the resolved dark state. Always a boolean, regardless of how it was decided. Use for visual logic ("show the moon icon").getScheme()→'light' | 'dark' | 'system'— the value the user (or config) chose. Returns'system'when following the OS,'light'/'dark'when explicitly picked. Use for binding a 3-way control.getDefaultScheme()→'light' | 'dark' | 'system'— the configured default.getSchemeSource()→'cookie' | 'default'—'cookie'if the user explicitly chose anything (including'system'),'default'if the config fallback is being used.
<button onclick={() => { setTheme(getDefaultTheme()); setScheme(getDefaultScheme()); }}>
Reset to defaults
</button>Loading state
Theme chunks are dynamic imports — the first switch to a theme fetches its CSS chunk over the network. While that's in flight you can show a loading indicator:
<script>
import { isLoadingTheme, getLoadingTheme, getThemes, setTheme } from '../themes';
</script>
<!-- global progress bar — shown while any theme is loading -->
{#if isLoadingTheme()}
<div class="fixed top-0 inset-x-0 h-1 bg-primary z-50 animate-pulse"></div>
{/if}
<!-- per-button spinner — shown only on the button being loaded -->
{#each getThemes() as name}
<button onclick={() => setTheme(name)}>
{name}
{#if isLoadingTheme(name)}
<svg class="animate-spin w-3 h-3"><!-- ... --></svg>
{/if}
</button>
{/each}
<!-- or read the loading name as a string (flat mode) -->
{#if getLoadingTheme()}
<p>Loading {getLoadingTheme()}…</p>
{/if}Both functions also fire when another tab broadcasts a theme change and this tab needs to fetch the chunk to keep up. After cache, switching is instant and these stay false.
Multi-axis themes
Sometimes one theme name isn't enough — you want a palette and a corner radius and a density, each chosen independently, all composing into the page at once. That's a multi-axis (axed) themes config: several orthogonal axes, each contributing its own <style> layer, switchable on their own.
Flat → axed
A flat themes is a flat record of loaders (the quickstart). An axed themes is a record of axes, each an inner record of loaders:
createThemes({
themes: {
palette: {
salmon: () => import('./themes/salmon.css?inline'),
sapphire: () => import('./themes/sapphire.css?inline')
},
radius: {
sharp: () => import('./themes/radius-sharp.css?inline'),
round: () => import('./themes/radius-round.css?inline')
}
},
defaultThemes: { palette: 'salmon', radius: 'sharp' }
});The shape is detected automatically: any record value flips the config to axed. Flat uses defaultTheme (a single name); axed uses defaultThemes (per-axis, and may be partial — an omitted axis falls back to its first declared theme). The two are mutually exclusive.
Switching and composition
Theme names are unique across a config's axes, so setTheme needs only the name — it finds the axis that owns it and switches that axis, leaving the others untouched:
setTheme('salmon'); // finds 'salmon' in the palette axis, switches it
setTheme('round'); // switches the radius axis → salmon + round now composesetTheme(name, scheme?) still takes the optional scheme arg, behaving exactly like setScheme (see Scheme).
Mixed: bare loaders fold into a default axis
You can mix bare loaders and named axes. Bare loaders fold into an implicit axis named default:
themes: {
bubblegum: () => import('./themes/bubblegum.css?inline'), // ┐ → default axis
candyland: () => import('./themes/candyland.css?inline'), // ┘
radius: {
sharp: () => import('./themes/radius-sharp.css?inline'),
round: () => import('./themes/radius-round.css?inline')
}
}
// → default axis = { bubblegum, candyland }, radius axis = { sharp, round }If you also declare a named default axis, the bare loaders merge into it (no conflict):
themes: {
bubblegum: () => import('./b.css?inline'), // bare
default: { candyland: () => import('./c.css?inline') }, // explicit `default` axis
palette: { salmon: () => import('./s.css?inline') }
}
// → default axis = { bubblegum, candyland }, palette axis = { salmon }On a same-name collision between a bare loader and an explicit default entry, the explicit entry wins (it's an override, not an error). This merge happens before the duplicate-name check runs.
Per-axis return shapes
In an axed config, the getters return per-axis objects keyed by axis name instead of bare strings:
getCurrentTheme(); // { palette: 'salmon', radius: 'sharp' }
getThemes(); // { palette: ['salmon','sapphire'], radius: ['sharp','round'] }
getDefaultTheme(); // { palette: 'salmon', radius: 'sharp' }
getThemeSource(); // { palette: 'cookie', radius: 'default' }
isLoadingTheme(); // true if ANY axis is loading
isLoadingTheme('round'); // true if 'round' (i.e. its radius axis) is loading
getLoadingTheme(); // { radius: 'round' } — only axes mid-load; {} when idlegetLoadingTheme() in an axed config returns an object containing only the axes currently loading, mapping axis → the in-flight theme name (its keys are the loading axes; nothing loading → {}). In a flat config it stays string | null, as in the quickstart.
Per-axis cookies and <style> elements
Each axis persists to its own cookie — theme-${axis} — and renders into its own <style id="svelte-themes-${axis}">. A flat config keeps the unsuffixed theme cookie and a single <style id="svelte-themes">. Axes are layered in declaration order (last axis wins on variable conflicts), so order them so the more specific axis comes last. Full table in Cookies.
Worked example — the demo's palette × radius
The demo composes a palette axis (43 colour themes) with a radius axis (corner roundness):
// src/themes.ts
import { createThemes } from '@plcharriere/svelte-themes';
export const {
setTheme, getThemes, getCurrentTheme, getDefaultTheme,
getThemeSource, isLoadingTheme, getLoadingTheme
} = createThemes({
defaultThemes: { palette: 'default', radius: 'soft' },
themes: {
palette: {
default: () => import('./themes/default.css?inline'),
bubblegum: () => import('./themes/bubblegum.css?inline'),
catppuccin: () => import('./themes/catppuccin.css?inline'),
// ...40 more
},
radius: {
sharp: () => import('./themes/radius-sharp.css?inline'),
soft: () => import('./themes/radius-soft.css?inline'),
round: () => import('./themes/radius-round.css?inline')
}
}
});A picker per axis just iterates getThemes() per key:
<script>
import { getThemes, getCurrentTheme, setTheme } from '../themes';
</script>
{#each Object.entries(getThemes()) as [axis, names]}
<select onchange={(e) => setTheme(e.currentTarget.value)} value={getCurrentTheme()[axis]}>
{#each names as name}
<option value={name}>{name}</option>
{/each}
</select>
{/each}Scoped themes
Same library, opt-in. Reach for scopes when different sections of your site need independent theme choices that don't bleed into each other — typically a marketing/landing surface and an admin/dashboard surface. Exactly one scope is active at a time; the URL picks it.
Each scope is self-contained — it declares its own themes (flat, axed, or mixed) and its own default. There is no shared top-level theme registry; nothing is inherited between scopes.
If your app has one theme system, stay flat (or axed). Scopes add concepts you don't need.
Config
// src/themes.ts
import { createScopedThemes } from '@plcharriere/svelte-themes';
export const { landing, admin, getActiveScope } = createScopedThemes({
// scheme stays top-level — shared across scopes by default
defaultScheme: 'system',
scopes: {
landing: {
match: '/', // first scope is also the no-match fallback
themes: {
sunset: () => import('./themes/sunset.css?inline'),
ocean: () => import('./themes/ocean.css?inline')
},
defaultTheme: 'sunset'
},
admin: {
match: '/admin',
// a scope's themes can themselves be axed
themes: {
density: {
compact: () => import('./admin/compact.css?inline'),
comfy: () => import('./admin/comfy.css?inline')
},
accent: {
blue: () => import('./admin/blue.css?inline'),
green: () => import('./admin/green.css?inline')
}
},
defaultThemes: { density: 'compact', accent: 'blue' }
}
}
});A flat scope uses defaultTheme; an axed scope uses defaultThemes (per-axis, may be partial). A scope omitting its default falls back to each axis's first declared theme.
Theme names are scope-local. sunset may exist in two scopes independently — admin.setTheme(name) only searches admin's themes, so there's no global collision. Names must be unique within a scope (so setTheme dispatch is unambiguous). Sharing a loader across scopes is your call — hoist it to a const and reference it in each scope.
Return shape
The scope handles are flat top-level keys, alongside getActiveScope. Each handle's return shape follows that scope's themes shape — a flat scope returns strings, an axed scope returns per-axis objects (exactly like createThemes):
landing.setTheme('ocean'); // flat scope, typed: 'sunset' | 'ocean'
landing.getCurrentTheme(); // 'ocean' (string)
admin.setTheme('compact'); // axed scope, typed: 'compact'|'comfy'|'blue'|'green'
admin.getCurrentTheme(); // { density: 'compact', accent: 'blue' }
admin.getThemes(); // { density: ['compact','comfy'], accent: ['blue','green'] }
admin.getThemeSource(); // { density: 'cookie', accent: 'default' }
admin.getLoadingTheme(); // { density: 'comfy' } — only axes mid-load; {} idle
getActiveScope(); // 'landing' | 'admin' — tracks the routeEach scope key carries only the theme functions (setTheme, getThemes, getCurrentTheme, getDefaultTheme, getThemeSource, isLoadingTheme, getLoadingTheme), narrowly typed. Use them when you statically know which scope you're in (e.g. a component that only renders under /admin). getActiveScope is the only library-provided key in the return — createScopedThemes throws if a scope is named getActiveScope.
Smart root dispatchers
The theme functions exported from the package root — setTheme, getThemes, getCurrentTheme, getDefaultTheme, getThemeSource, isLoadingTheme, getLoadingTheme — become active-scope dispatchers in a scoped app. They resolve the scope from the current route at call time and delegate:
<!-- src/routes/+layout.svelte — works on every route, no scope hardcoded -->
<script>
import { getThemes, getCurrentTheme, setTheme } from '@plcharriere/svelte-themes';
</script>
<select onchange={(e) => setTheme(e.currentTarget.value)} value={getCurrentTheme()}>
{#each getThemes() as name}
<option value={name}>{name}</option>
{/each}
</select>Under /, this picker offers the landing scope's themes and writes the landing cookie; under /admin, it offers admin's and writes admin's. The same component, no if (route.startsWith('/admin')) branching. getThemes() returns the active scope's themes (and in the active scope's shape — strings for flat, per-axis objects for axed).
Typing trade-off: root dispatchers stay loosely typed (the active scope isn't known at compile time). For narrow typing, use the destructured scope handle (admin.setTheme(...)).
Scheme functions are root-only (setScheme, toggleScheme, isDark, getScheme, getDefaultScheme, getSchemeSource) — there's no scope.setScheme. They act on the active scope's scheme when sharedScheme: false, or the one global scheme when shared (the default).
The match field
match decides which scope a URL belongs to. Evaluated against the runtime pathname on every request (server) and every navigation (client). Unchanged from 0.2.x.
type Matcher = string | readonly string[] | ((url: URL) => boolean);| Form | Example | Behavior |
|---|---|---|
| String | '/admin' | Segment-aware prefix — matches /admin, /admin/users/42, not /administrator. |
| Array | ['/admin', '/dashboard'] | Any-of. Puts disjoint route trees in one scope. |
| Predicate | (url) => url.pathname.split('/')[2] === 'admin' | Full control. Use when a dynamic segment is part of the boundary (e.g. i18n prefixes /en/admin). |
Resolution: longest matching prefix wins among string/array scopes. Predicates are evaluated in declaration order (first match wins). A URL matching nothing falls back to the first declared scope — put your primary one first. match works on the resolved runtime path; it doesn't parse SvelteKit route IDs ([id], [...rest]).
Shared vs per-scope scheme
sharedScheme (default true) is the master switch:
true— all scopes share one scheme cookie (scheme), the top-leveldefaultSchemeapplies everywhere, and per-scopedefaultSchemeis ignored. That's right ~95% of the time (dark mode is a user preference, not a section property).false— each scope gets its own scheme cookie (scheme-${scope}) and its owndefaultScheme(falling back to the top-level one). Toggling dark on/admindoesn't touch/.
createScopedThemes({
defaultScheme: 'system',
sharedScheme: false, // every scope independent; default is true
scopes: {
landing: { match: '/', themes: { /* ... */ }, defaultTheme: 'sunset' },
admin: { match: '/admin', themes: { /* ... */ }, defaultTheme: 'slate',
defaultScheme: 'light' } // admin first-visit defaults to light
}
});Cookie names
Cookie names are derived automatically — see the full Cookies table. In short: a scope's theme cookie is theme-${scope} (flat) or theme-${scope}-${axis} (axed); under sharedScheme: false, its scheme cookie is scheme-${scope}. Isolation comes from these distinct names, not paths — all cookies use path=/, and the server reads the matched scope's cookies. So setTheme('ocean') on / writes theme-landing; coming back later reads it unchanged.
No-flash cross-scope navigation
When the user SPA-navigates from one scope to another (/admin → /), the target scope's axis CSS is fetched first, then the <style> element(s) and the dark class are swapped atomically — the same flash-free guarantee as setTheme. Axes present in both scopes reuse their element (content swap); axes only in the old scope are removed; axes only in the new scope are created. Under sharedScheme: false the scheme is re-resolved and re-applied too.
There is no idle preload — the library does not speculatively fetch other scopes' CSS, since a user may never visit them. Each scope's CSS loads only when it's actually navigated to; the first cross-scope hop pays its own one-time load, then caches.
Wiring SvelteKit
Identical to the flat Quickstart — same hooks.server.ts, same minimal app.html, same import '../themes' in the root layout. createThemesHandle() reads the scope registry and matches each request to the right scope.
For per-scope narrow typing, import the destructured handle in a scope-specific layout:
<!-- src/routes/admin/+layout.svelte -->
<script>
import { admin } from '../../themes';
let { children } = $props();
</script>
<button onclick={() => admin.setTheme('comfy')}>Comfy density</button>
{@render children()}For everywhere-else code (a header under any route), use the root dispatchers — they auto-route to whichever scope is active.
Validation
createScopedThemes throws at call time on:
- An empty
scopesobject, or a scope with an emptythemes, or an axis with no themes inside it. - A scope's
defaultTheme(flat) referencing a theme not in that scope, ordefaultThemes(axed) referencing an unknown axis key or a theme not in that axis. - Duplicate theme names across a scope's axes (checked after the bare→
defaultmerge) — names may repeat across scopes, just not within one. - Two scopes with identical
matchpatterns. - Any two derived cookie names colliding across the whole config (hyphenated scope/axis names can alias).
- Axis or scope names not matching
^[A-Za-z0-9_-]+$(they become cookie names;defaultis valid). - A scope named
getActiveScope— reserved key. cookieTheme === cookieScheme— the two cookie families would alias.
Scheme
Light/dark/system is an independent axis — not one of the multi-axis themes. It keeps its own semantics, fully unchanged across releases: prefers-color-scheme, a system mode, the dark class on <html>, the Sec-CH-Prefers-Color-Scheme client hint, the boot script, and no-flash SSR. Axes never touch the dark class.
defaultScheme decides what first-time visitors (no cookie) see:
'system'(default) — followprefers-color-scheme. The server reads theSec-CH-Prefers-Color-Schemeclient hint; the boot script and amatchMedialistener cover browsers that don't send it.'light'/'dark'— force light or dark for first-time visitors, ignoring the OS preference.
setScheme('system') is always allowed, regardless of defaultScheme — the choice is persisted as scheme=system and survives reloads. setTheme(name, scheme?) takes an optional second arg that behaves exactly like setScheme (it acts on the active/global scheme, independent of which scope's handle you called setTheme on).
In a scoped app, scheme is governed by sharedScheme (see Shared vs per-scope scheme).
Cookies
Every cookie leads with its type token — theme for theme cookies, scheme for scheme cookies — then scope, then axis. So all theme cookies sort together, all scheme cookies sort together.
Theme — four-way, driven by (scoped?) × (axed?):
| | flat themes | axed themes |
|---|---|---|
| createThemes (no scope) | theme | theme-${axis} (bare loaders → theme-default) |
| createScopedThemes | theme-${scope} | theme-${scope}-${axis} (bare → theme-${scope}-default) |
Scheme — controlled by sharedScheme:
| | cookie |
|---|---|
| createThemes, or sharedScheme: true (default) | scheme |
| createScopedThemes + sharedScheme: false | scheme-${scope} |
All cookies use path=/. Derived names are validated against ^[A-Za-z0-9_-]+$.
Overriding the leading token. cookieTheme (default theme) and cookieScheme (default scheme) replace the leading word in every derived cookie — useful to avoid collisions with other libraries or tenants on the same domain. They are top-level only (one prefix for the whole app, never per-scope):
createScopedThemes({
cookieTheme: 'app-theme', // → app-theme-landing, app-theme-admin-density, …
cookieScheme: 'app-scheme', // → app-scheme (or app-scheme-${scope} when independent)
scopes: { /* ... */ }
});cookieTheme and cookieScheme must differ — the library throws if they're equal, since the two cookie families would alias.
TypeScript
The library uses TS 5+ const type parameters, so you write plain object literals — no as const, no explicit annotations:
const { admin } = createScopedThemes({
scopes: {
admin: {
match: '/admin',
themes: { slate: () => import('./slate.css?inline'),
graphite: () => import('./graphite.css?inline') },
defaultTheme: 'slate'
}
}
});
admin.setTheme('graphite'); // ✅
admin.setTheme('sunset'); // ❌ TS error — not in admin's themescreateThemes defaults are fully typed — defaultTheme / defaultThemes are checked against your themes (an unknown name or axis is a compile error). Per-scope defaults in createScopedThemes are permissive (typed as string / Record<string, string>) and validated at runtime instead — a deliberate trade-off so that whole-config scope inference still works; the per-scope handle narrowing (which reads themes) is fully preserved.
Exported types:
import type {
Scheme, // 'light' | 'dark' | 'system'
Matcher, // string | readonly string[] | ((url: URL) => boolean)
ThemeLoader, // () => Promise<string | { default: string }>
ThemesConfig, // createThemes config (flat | axed | mixed)
ThemesAPI, // one themes handle (flat-shaped or axed-shaped)
ScopeDecl, // one scope's config
ScopedConfig, // createScopedThemes config
ScopedAPI // scoped return — per-scope handles + getActiveScope
} from '@plcharriere/svelte-themes';API reference
Setup
| Export | Purpose |
| --- | --- |
| createThemes(config) | Register a one-scope themes system (flat, axed, or mixed) → a ThemesAPI handle. |
| createScopedThemes(config) | Register N self-contained scopes → per-scope handles + getActiveScope. |
| createThemesHandle() | Server entry (@plcharriere/svelte-themes/server), parameterless, works for all shapes. |
Theme (handle / per-scope handle)
Return shapes follow the themes shape: flat returns strings, axed returns per-axis objects.
| Export | Flat return | Axed return |
| --- | --- | --- |
| setTheme(name, scheme?) | switch theme (+ optional scheme), async | finds the owning axis, switches it |
| getThemes() | string[] | Record<axis, string[]> |
| getCurrentTheme() | string | Record<axis, string> |
| getDefaultTheme() | string | Record<axis, string> |
| getThemeSource() | 'cookie' \| 'default' | Record<axis, 'cookie' \| 'default'> |
| isLoadingTheme(name?) | boolean | boolean (no arg → any axis; name → that theme's axis) |
| getLoadingTheme() | string \| null | Partial<Record<axis, string>> (only axes mid-load; {} idle) |
Theme (package root — smart dispatchers in scoped apps)
| Export | Purpose |
| --- | --- |
| setTheme, getThemes, getCurrentTheme, getDefaultTheme, getThemeSource, isLoadingTheme, getLoadingTheme | Same functions as the handle, loosely typed. In a scoped app, dispatch to the active scope; in a one-scope app, identical to the handle. |
Scheme (package root, root-only)
| Export | Purpose |
| --- | --- |
| setScheme(scheme) | Set scheme. 'light' \| 'dark' \| 'system'. |
| toggleScheme() | Flip between 'light' and 'dark'. |
| getScheme() | The chosen value — 'light' / 'dark' / 'system'. |
| getDefaultScheme() | Configured default scheme. |
| getSchemeSource() | 'cookie' (user picked) / 'default' (config fallback). |
| isDark() | Resolved dark state — always boolean. |
In a scoped app these act on the active scope's scheme (sharedScheme: false) or the one global scheme (shared).
Scoped-only
| Export | Purpose |
| --- | --- |
| getActiveScope() | Name of the scope active on the current route. Tracks the route. |
Config reference
createThemes
createThemes({
themes: { /* flat | axed | mixed */ },
defaultTheme: 'name', // flat only — single name (default: first theme)
defaultThemes: { axis: 'name' }, // axed only — per-axis, may be partial
defaultScheme: 'system', // 'system' | 'light' | 'dark' (default 'system')
cookieTheme: 'theme', // optional — leading token for theme cookies
cookieScheme: 'scheme', // optional — leading token for scheme cookies
syncTabs: true, // optional — broadcast changes to other tabs
syncChannel: 'svelte-themes' // optional — BroadcastChannel name
});defaultTheme (flat) and defaultThemes (axed) are mutually exclusive — the one you can use is decided by your themes shape. There's only one scope, so no sharedScheme.
createScopedThemes
createScopedThemes({
scopes: {
scopeName: {
match: '/path', // string | string[] | (url: URL) => boolean
themes: { /* flat | axed | mixed — this scope's own loaders */ },
defaultTheme: 'name', // flat scope
defaultThemes: { axis: 'name' }, // axed scope, may be partial
defaultScheme: 'light' // used only when sharedScheme: false
}
},
defaultScheme: 'system', // shared scheme default (default 'system')
sharedScheme: true, // optional — false = every scope independent
cookieTheme: 'theme', // optional — top-level only, prefixes ALL theme cookies
cookieScheme: 'scheme', // optional — top-level only, prefixes ALL scheme cookies
syncTabs: true, // optional
syncChannel: 'svelte-themes' // optional
});syncTabs enables live cross-tab updates via BroadcastChannel — when one tab changes theme or scheme, every other open tab applies it immediately (messages are scope- and axis-tagged, so an admin change can't leak into a landing tab). syncChannel only matters if multiple apps share an origin and you want them isolated.
Server entry
import { createThemesHandle } from '@plcharriere/svelte-themes/server';
export const handle = createThemesHandle();Parameterless. Works identically for one-scope and scoped apps — it reads the registry, matches event.url.pathname against the registered scopes (one implicit scope for createThemes, N scopes for createScopedThemes, falling back to the first scope if none match), resolves each of the active scope's axis cookies and the scheme (shared or per-scope), loads the matching CSS, injects one <style> per axis plus the boot script before </head>, sets %dark% on <html>, and emits Accept-CH: Sec-CH-Prefers-Color-Scheme. The scopes in your config are the route mapping — no route arguments on the handle.
How it works
For each request the handle picks the active scope, then resolves theme + scheme from that scope's cookies:
- Active scope — match
event.url.pathname; longest matching string-prefix wins, otherwise the first declared scope. - Theme — for each of the active scope's axes, read its cookie (
theme/theme-${axis}/theme-${scope}/theme-${scope}-${axis}); if set and known, use it, else fall back to that axis's default. Load the CSS chunk and inject one<style>per axis (svelte-themesflat,svelte-themes-${axis}axed) before</head>. - Scheme — read the scheme cookie (
schemeshared, orscheme-${scope}per-scope):'dark'/'light'force it;'system'follows OS prefs (via theSec-CH-Prefers-Color-Schemeheader). No cookie → fall back todefaultScheme. Render as thedarkclass via%dark%+ the boot script.
The resolved per-axis state is stashed in an AsyncLocalStorage RequestState so the server-side getters return the same shape as the client. By the time the HTML reaches the browser, the right theme and scheme are already painted.
A tiny boot script is auto-injected right before </head>. It runs synchronously before paint and toggles the dark class from matchMedia('(prefers-color-scheme: dark)') when the user is in system mode — covering first-ever visits and browsers that don't send the client hint. A matchMedia listener keeps the class updated if the OS preference changes mid-session.
On the client, setTheme(name) writes the owning axis's cookie; setScheme(...) writes the scheme cookie. With syncTabs on (the default), each change also posts a scope- and axis-tagged message on a BroadcastChannel; other tabs validate and apply it locally without re-broadcasting, so there's no echo.
Using with Tailwind CSS v4
The library doesn't know or care that you use Tailwind — it just injects each theme's CSS string into a <style>. But how that CSS is produced matters. With a ?inline loader, each theme file is compiled by Tailwind in isolation and injected as a separate <style>, rather than being bundled into your one main stylesheet. That single fact drives every rule below. Get these right and themes compose perfectly; get them wrong and you'll see "unknown utility class" errors, missing shadows/fonts, or styling that's subtly off versus a direct @import.
The golden rule: global build vs. theme files
Split responsibilities cleanly:
| | Your global stylesheet (app.css / globals.css, bundled & scanned with your markup) | Each theme file (loaded via ?inline, injected separately) |
|---|---|---|
| @import "tailwindcss" | ✅ once, here | ❌ never (it re-emits all of Tailwind into every theme) |
| @custom-variant, @utility | ✅ here | ❌ |
| @theme mapping | ✅ the complete mapping, here, once | ❌ — it silently does nothing when injected (see below) |
| @reference "…app.css" | — | ✅ only if the file uses @apply |
| @layer theme, base, components, utilities; | (already from @import "tailwindcss") | ✅ if the file has layered rules (@apply in @layer …) |
| :root { … } / .dark { … } values | optional fallbacks (see #4) | ✅ this is all a theme file should really contain |
If your themes are pure :root/.dark variable values (the shadcn/tweakcn shape), a theme file needs nothing else — no @reference, no @theme, no imports.
1. @apply in a theme file needs @reference
If a theme file uses @apply with any non-core utility (bg-primary, ring-ring/50, text-muted-foreground, …), compiling it standalone fails:
Cannot apply unknown utility class `ring-ring/50`Those utilities come from your @theme tokens, which the isolated compile can't see. Add @reference at the top, pointing at your main stylesheet — it loads your theme/utilities as context only, emitting no CSS — so @apply resolves:
@reference "../../app.css"; /* relative path to your main stylesheet */
@layer base {
.my-component { @apply bg-primary ring-ring/50 rounded-md; }
}2. Pin the cascade-layer order
A separately-compiled chunk only declares the layers it uses (e.g. @layer base), not Tailwind's full order. CSS decides layer priority by where each layer name first appears across the whole document, so an injected @layer base can land in a different position relative to theme / components / utilities than it has when bundled — which flips whether your utility classes override the file's component rules. Worse, it can differ between dev and prod (Vite injects your main CSS at different times). Pin the canonical order at the top of each such file so it's deterministic:
@reference "../../app.css";
@layer theme, base, components, utilities; /* make layer order explicit & stable */
@layer base {
/* …your @apply-ed rules… */
}(Files that are only :root/.dark variables have no layered rules and don't need this.)
3. @theme must live in your global CSS — once (the big one)
A @theme block inside a separately-injected theme file does nothing. Move the entire mapping to your global stylesheet.
Why: @theme doesn't emit "active" CSS — it configures which utility classes Tailwind generates and what var() each expands to. Those classes are generated on demand, from the markup Tailwind scans, within that one build. A @theme sitting in an isolated chunk has no markup to generate utilities for, and can't reach across the compilation boundary to add utilities to your main build (the one that actually scans your .svelte files). So its mappings are silently dropped — shadow-md falls back to Tailwind's default gray, font-serif to the default serif, and the values the theme did inject (--shadow-md, --font-serif) have nothing referencing them.
Symptom: colors switch correctly but shadows, fonts, or radii look wrong/missing compared to importing the file directly. That's a
@thememapping that only existed in the theme file.
So your global @theme inline must map every token any theme uses — colors, fonts (incl. --font-serif), the full radius scale, shadows, tracking, etc.:
/* app.css / globals.css — bundled with your markup. Declared ONCE. */
@import "tailwindcss";
@theme inline {
--color-primary: var(--primary);
--color-background: var(--background);
--font-serif: var(--font-serif);
--shadow-md: var(--shadow-md);
--radius-sm: calc(var(--radius) - 4px);
/* …every token ANY theme references… */
}/* themes/bubblegum.css — injected separately. VALUES ONLY. */
:root { --primary: oklch(0.62 0.18 348); --shadow-md: 3px 3px 0 …; --font-serif: Lora, serif; --radius: 0.4rem; }
.dark { --primary: …; }@theme inline is also what makes live switching work: it compiles bg-primary to var(--primary) (not a baked color), so when the library swaps the injected :root { --primary } value, every bg-primary on the page re-themes instantly with no recompile.
The shadcn/tweakcn theme exports ship a
@theme inline { … }block inside each theme file. Delete it from the theme files and keep one merged copy in your global CSS — keep only:root/.darkin the theme files.
Why @apply and @layer work in a theme file but @theme doesn't: @apply and @layer emit concrete CSS that travels inside the injected chunk (@apply copies a utility's declarations right into your rule; @layer just wraps emitted CSS). @theme only configures utility generation for the build that owns it — there's nothing to emit, and the configuration can't cross into your separate main build.
4. Make global fallback values overridable
If your global CSS also defines a default palette (:root { --primary: … }) as a fallback, wrap those blocks in @layer base. The injected theme files define :root/.dark unlayered, and in CSS unlayered rules beat layered ones regardless of source order — so layering your globals guarantees the active theme always wins:
/* globals.css */
@layer base {
:root { --primary: oklch(0.2 0 0); /* fallback only */ }
.dark { --primary: oklch(0.9 0 0); }
}Without the layer, both your globals and the theme are unlayered, so the last one in the document wins — which flips between dev and prod depending on injection order, and typically lets your globals override the theme. (Tokens defined only in globals still apply normally; layering matters only where both sides define the same variable.)
Theme-file checklist
For each file loaded via ?inline:
- [ ] No
@import "tailwindcss"(it re-emits the whole framework per theme) - [ ] No
@themeblock (moved to global CSS) - [ ]
@reference "…/app.css"at the top — only if the file uses@apply - [ ]
@layer theme, base, components, utilities;— only if it has layered (@apply-ed) rules - [ ] Otherwise: just
:root { … }/.dark { … }values
And once, in your global stylesheet:
- [ ] The complete
@thememapping (every token any theme uses — colors, fonts, radius, shadows, …) - [ ] Any fallback
:root/.darkvalues wrapped in@layer base
Dev note: restart after editing theme CSS
The server compiles each theme's CSS once and caches it for the process lifetime (CSS is static per build). In development that means editing a theme file (or your global @theme) may not show up on reload until you restart the dev server, which clears the cache. Production builds compile fresh, so this is purely a dev-time wrinkle.
Migrating from 0.2.x
app.html— remove two things (breaking). Deletedata-theme="%theme%"from<html>and delete the<style id="svelte-themes">%theme-css%</style>line. The handle now injects the theme<style>element(s) directly. Only%dark%remains on<html>:<html lang="en" class="%dark%">data-themeis gone. The page is themed entirely by the CSS variables inside the injected<style>element(s); client state comes from cookies. If you relied on[data-theme="…"]selectors, that hook no longer exists — key off the CSS variables instead.- Cookie names renamed (breaking). Scoped cookies moved the type token to the front:
${scope}-theme→theme-${scope},${scope}-scheme→scheme-${scope}. Existing cookies under the old names are ignored (users fall back to defaults once, then re-persist). - Scoped config is now self-contained per scope. There's no top-level shared
themesregistry and nothemes: string[]subset selector. Each scope declares its ownthemes(which can itself be axed) and its owndefaultTheme/defaultThemes. Move your theme loaders into the scope that uses them. getThemeSourceis on the handle now (it was root-only in 0.2.5) — available on both the handle and the package root.
License
MIT
