The pain this guide solves
Svelte's scoped styles are perfect for components but terrible for a shared design system. Every component re-declares the same colors. Changing the primary color means touching dozens of files, and dark mode is a separate stylesheet nobody keeps in sync.
salt-theme-gen with SvelteKit
What you will build
- CSS variables generated at build time and injected into
app.html— server-rendered in every page’s<head> - A Svelte writable store for dark mode with
localStoragepersistence - Scoped component styles that reference
var(--color-*)with no imports needed - Optional typed token access via a Svelte context
Time required: 15 minutes.
Install
npm install salt-theme-gen
Step 1 — Generate theme
Create src/lib/theme.ts:
import { generateTheme } from 'salt-theme-gen';
import type { GeneratedThemeMode } from 'salt-theme-gen';
export const theme = generateTheme({
preset: 'ocean',
spacing: 'default',
radius: 'default',
fontSize: 'default',
});
function kebab(str: string): string {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}
function modeToVars(mode: GeneratedThemeMode): string {
const lines: string[] = [];
for (const [k, v] of Object.entries(mode.colors))
lines.push(`--color-${kebab(k)}: ${v};`);
for (const [k, v] of Object.entries(mode.surfaceElevation))
lines.push(`--surface-${k}: ${v};`);
for (const [k, v] of Object.entries(mode.spacing))
lines.push(`--space-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.radius))
lines.push(`--radius-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.fontSizes))
lines.push(`--text-${k}: ${v}px;`);
for (const [intent, states] of Object.entries(mode.states))
for (const [state, val] of Object.entries(states as Record<string, string>))
lines.push(`--state-${intent}-${state}: ${val};`);
return lines.join('\n ');
}
export const lightVars = modeToVars(theme.light);
export const darkVars = modeToVars(theme.dark);
export const themeCSS = `
:root { ${lightVars} }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ${darkVars} } }
:root[data-theme="dark"] { ${darkVars} }
:root[data-theme="light"] { ${lightVars} }
`.trim();
Step 2 — Inject into app.html
SvelteKit’s app.html is the outer HTML shell for every page. Add a %sveltekit.head% replacement that includes the theme:
First, generate the CSS to a static string. Create a one-time script scripts/generate-theme-css.ts:
import { writeFileSync } from 'node:fs';
import { themeCSS } from '../src/lib/theme.ts';
writeFileSync('static/theme.css', themeCSS);
Run it once and add to your build step:
// package.json
{
"scripts": {
"prebuild": "npx tsx scripts/generate-theme-css.ts",
"predev": "npx tsx scripts/generate-theme-css.ts",
"build": "vite build",
"dev": "vite dev"
}
}
Then reference it in app.html:
<!-- app.html -->
<!doctype html>
<html lang="en" %sveltekit.attributes%>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<link rel="stylesheet" href="/theme.css" />
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light') {
document.documentElement.setAttribute('data-theme', t);
}
})();
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
Alternative — inline via layout load
If you prefer not to write a static file, inject the CSS string through SvelteKit’s layout load:
// src/routes/+layout.server.ts
import { themeCSS } from '$lib/theme';
export function load() {
return { themeCSS };
}
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<style>${data.themeCSS}</style>`}
</svelte:head>
<slot />
{@html} injects raw HTML — the only way to get a <style> tag with dynamic content into Svelte’s <svelte:head> without Svelte scoping it.
Step 3 — Global base styles
Create src/app.css:
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background-color: var(--color-background);
color: var(--color-text);
font-size: var(--text-md);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
transition: background-color 0.2s, color 0.2s;
}
a { color: var(--color-primary); }
a:hover { color: var(--state-primary-hover); }
Import in +layout.svelte:
<script>
import '../app.css';
</script>
Step 4 — Dark mode store
Create src/lib/stores/themeMode.ts:
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
type ThemeMode = 'light' | 'dark' | 'system';
function createThemeMode() {
const initial: ThemeMode = browser
? (localStorage.getItem('theme') as ThemeMode) ?? 'system'
: 'system';
const { subscribe, set } = writable<ThemeMode>(initial);
function apply(mode: ThemeMode) {
if (!browser) return;
const resolved = mode === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: mode;
document.documentElement.setAttribute('data-theme', resolved);
localStorage.setItem('theme', mode);
set(mode);
}
function toggle() {
const current = document.documentElement.getAttribute('data-theme');
apply(current === 'dark' ? 'light' : 'dark');
}
if (browser) {
apply(initial);
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
const saved = localStorage.getItem('theme') as ThemeMode;
if (saved === 'system' || !saved) apply('system');
});
}
return { subscribe, apply, toggle };
}
export const themeMode = createThemeMode();
Step 5 — ThemeToggle component
<!-- src/lib/components/ThemeToggle.svelte -->
<script lang="ts">
import { themeMode } from '$lib/stores/themeMode';
</script>
<button
class="toggle"
on:click={themeMode.toggle}
aria-label="Toggle dark mode"
>
{$themeMode === 'dark' ? '☀ Light' : '◐ Dark'}
</button>
<style>
.toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 6px 10px;
cursor: pointer;
color: var(--color-text);
font-size: var(--text-sm);
transition: border-color 0.15s;
}
.toggle:hover { border-color: var(--color-primary); }
</style>
Using tokens in components
In scoped <style> blocks
<!-- src/lib/components/Button.svelte -->
<script lang="ts">
export let intent: 'primary' | 'danger' | 'success' = 'primary';
export let disabled = false;
</script>
<button class="btn btn--{intent}" {disabled}>
<slot />
</button>
<style>
.btn {
border: none;
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-lg);
font-size: var(--text-md);
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn--primary { background: var(--color-primary); color: var(--color-on-primary); }
.btn--danger { background: var(--color-danger); color: var(--color-on-danger); }
.btn--success { background: var(--color-success); color: var(--color-on-success); }
.btn--primary:hover { background: var(--state-primary-hover); }
.btn--danger:hover { background: var(--state-danger-hover); }
.btn--success:hover { background: var(--state-success-hover); }
.btn:focus-visible {
outline: 2px solid var(--state-primary-focused);
outline-offset: 2px;
}
.btn:disabled {
background: var(--state-primary-disabled);
cursor: not-allowed;
}
</style>
With typed tokens via Svelte context
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
import { derived } from 'svelte/store';
import { theme } from '$lib/theme';
import { themeMode } from '$lib/stores/themeMode';
const modeTokens = derived(themeMode, ($mode) =>
$mode === 'dark' ? theme.dark : theme.light,
);
setContext('themeMode', modeTokens);
</script>
<slot />
<!-- In any child component -->
<script lang="ts">
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import type { GeneratedThemeMode } from 'salt-theme-gen';
const mode = getContext<Readable<GeneratedThemeMode>>('themeMode');
</script>
<div style:background={$mode.surfaceElevation.card}
style:border-radius="{$mode.radius.lg}px"
style:padding="{$mode.spacing.xl}px">
<slot />
</div>
Svelte’s style: directive binds individual CSS properties reactively — cleaner than constructing an object string.
Card component
<!-- src/lib/components/Card.svelte -->
<script lang="ts">
export let title: string;
export let body: string;
</script>
<article class="card">
<h3 class="title">{title}</h3>
<p class="body">{body}</p>
<slot name="footer" />
</article>
<style>
.card {
background: var(--surface-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
transition: border-color 0.15s;
}
.card:hover { border-color: var(--color-primary); }
.title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
margin: 0 0 var(--space-sm);
}
.body {
font-size: var(--text-md);
color: var(--color-muted);
line-height: 1.7;
margin: 0;
}
</style>
File structure summary
src/
├── app.css ← token-based base styles
├── app.html ← theme.css link + inline script
├── lib/
│ ├── theme.ts ← generateTheme() + CSS var export
│ ├── stores/
│ │ └── themeMode.ts ← writable store for dark mode
│ └── components/
│ ├── ThemeToggle.svelte
│ ├── Button.svelte
│ └── Card.svelte
└── routes/
├── +layout.svelte ← global CSS import + context setup
└── +layout.server.ts ← optional: serve themeCSS via load
scripts/
└── generate-theme-css.ts ← writes static/theme.css at build time
Checklist
src/lib/theme.tsgenerates and exportsthemeCSS✓app.htmllinks/theme.cssand runs inline preference script ✓src/app.cssusesvar(--color-background)on body ✓themeModestore setsdata-themeon<html>✓ThemeToggle.svelteimports and callsthemeMode.toggle✓- Component
<style>blocks use CSS variables only — no hardcoded values ✓
Svelte 5 runes: If you are using Svelte 5 with runes mode, replace the writable store with a $state rune and $derived for the mode tokens. The CSS variable approach and app.html injection are identical — only the reactivity API changes.
Live demo
Open this integration in StackBlitz — fully working, editable in your browser.
Open in StackBlitz →