A customizable theme picker component for Svelte 5 applications.
npm install svelte-theme-picker
# or
pnpm add svelte-theme-picker
# or
yarn add svelte-theme-picker
<script>
import { ThemePicker } from 'svelte-theme-picker';
</script>
<ThemePicker />
This adds a floating theme picker button to the bottom-right of your page.
| Prop | Type | Default | Description |
|---|---|---|---|
config |
ThemePickerConfig |
{} |
Configuration options |
store |
ThemeStore |
undefined |
Custom theme store |
position |
'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' |
'bottom-right' |
Position of trigger button |
showTrigger |
boolean |
true |
Show floating button (false = inline mode) |
layout |
'vertical' | 'horizontal' |
'vertical' |
Layout direction for inline mode |
onThemeChange |
(id: string, theme: Theme) => void |
undefined |
Callback when theme changes |
<!-- Vertical list with names and descriptions -->
<ThemePicker showTrigger={false} layout="vertical" />
<!-- Horizontal grid of color swatches (compact) -->
<ThemePicker showTrigger={false} layout="horizontal" />
interface ThemePickerConfig {
storageKey?: string; // localStorage key (default: 'svelte-theme-picker')
defaultTheme?: string; // Default theme ID (default: 'dreamy')
themes?: Record<string, Theme>; // Custom themes
cssVarPrefix?: string; // CSS variable prefix
}
When using SvelteKit with SSR, themes are applied after JavaScript loads, causing a flash of unstyled content (FOUC). Use the ThemeHead component or SSR utilities to prevent this.
The easiest way to prevent theme flash in SvelteKit:
<!-- src/routes/+layout.svelte -->
<script>
import { ThemeHead, ThemePicker, defaultThemes } from 'svelte-theme-picker';
</script>
<ThemeHead
themes={defaultThemes}
storageKey="my-app-theme"
defaultTheme="dreamy"
preloadFonts={true}
/>
<ThemePicker config={{ themes: defaultThemes, storageKey: 'my-app-theme' }} />
<slot />
The ThemeHead component:
no-transitions class to prevent transition animations during hydration| Prop | Type | Default | Description |
|---|---|---|---|
themes |
Record<string, Theme> |
required | All available themes |
storageKey |
string |
'svelte-theme-picker' |
localStorage key |
defaultTheme |
string |
first theme | Default theme ID |
cssVarPrefix |
string |
'' |
CSS variable prefix |
preventTransitions |
boolean |
true |
Prevent transition animations during hydration |
preloadFonts |
boolean |
false |
Enable Google Fonts preloading |
fontConfig |
FontConfig |
{ provider: 'google' } |
Font preloading configuration |
For more control, use the SSR utilities to generate blocking scripts:
// src/hooks.server.ts
import { generateSSRHead } from 'svelte-theme-picker';
import { myThemes } from './themes';
export async function handle({ event, resolve }) {
return resolve(event, {
transformPageChunk: ({ html }) => {
const ssrHead = generateSSRHead({
themes: myThemes,
storageKey: 'my-app-theme',
defaultTheme: 'dreamy',
});
return html.replace('</head>', `${ssrHead}</head>`);
}
});
}
After hydration, remove the no-transitions class to enable animations:
<!-- src/routes/+layout.svelte -->
<script>
import { browser } from '$app/environment';
import { onMount } from 'svelte';
onMount(() => {
// Small delay to ensure hydration is complete
setTimeout(() => {
document.documentElement.classList.remove('no-transitions');
}, 50);
});
</script>
import {
generateBlockingScript, // Generate minified blocking script string
generateSSRHead, // Generate complete head HTML (script + styles + fonts)
applyThemeToElement, // Apply theme to an element (sync, for blocking scripts)
getThemeCSS, // Get CSS variable declarations as string
extractFonts, // Extract font names from a theme
generateFontPreloadLinks, // Generate Google Fonts preload links
themeSchema, // CSS variable mapping (for consistency)
} from 'svelte-theme-picker';
Enable automatic theme sync across browser tabs:
<script>
import { createThemeStore, ThemePicker } from 'svelte-theme-picker';
const store = createThemeStore({
syncTabs: true, // Enable cross-tab sync
storageKey: 'my-app-theme',
});
</script>
<ThemePicker store={store} />
When a user changes the theme in one tab, all other tabs will automatically update. The store listens for storage events and syncs the theme state.
To clean up listeners when done:
store.destroy(); // Removes storage event listener
If you're using an external store and the ThemePicker doesn't update when the store changes externally, use the {#key} pattern to force a re-render:
<script>
import { ThemePicker, createThemeStore } from 'svelte-theme-picker';
const themeStore = createThemeStore({ /* config */ });
let currentThemeId = $state('dreamy');
// Subscribe to track external changes
themeStore.subscribe((themeId) => {
currentThemeId = themeId;
});
</script>
<!-- Force re-render when theme changes externally -->
{#key currentThemeId}
<ThemePicker store={themeStore} />
{/key}
Note: The
ThemePickercomponent captures its configuration once at mount. This is intentional for performance. Use{#key}to create a new instance when props need to change.
The ThemePicker component is completely optional. You can use just the store and utilities for full programmatic control without rendering any UI. This is useful when:
<script>
// No ThemePicker component needed!
import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
import { onMount } from 'svelte';
onMount(() => {
// Load theme from user preferences, database, API, etc.
const userTheme = getUserPreference() || 'dreamy';
themeStore.setTheme(userTheme);
applyTheme(defaultThemes[userTheme]);
// Optionally subscribe to persist changes
return themeStore.subscribe((themeId) => {
saveUserPreference(themeId);
});
});
</script>
<!-- Your app content - no picker UI rendered -->
<slot />
You get all the benefits (localStorage persistence, CSS variable application, TypeScript types) without any visible theme picker.
Perfect for form-based theme selection where you want to preview themes without persisting them:
<script>
import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
import { onMount } from 'svelte';
let selectedTheme = 'dreamy';
onMount(() => {
// When the page unmounts, revert to persisted theme
return () => {
themeStore.revertPreview();
};
});
function previewTheme(themeId: string) {
selectedTheme = themeId;
// Preview without persisting to localStorage
themeStore.previewTheme(themeId);
applyTheme(defaultThemes[themeId]);
}
function saveTheme() {
// Persist the selection
themeStore.setTheme(selectedTheme);
}
</script>
<select onchange={(e) => previewTheme(e.target.value)}>
{#each Object.entries(defaultThemes) as [id, theme]}
<option value={id}>{theme.name}</option>
{/each}
</select>
<button onclick={saveTheme}>Save Theme</button>
interface ThemeStore {
// ... standard methods ...
/** Preview a theme temporarily without persisting */
previewTheme: (themeId: string) => void;
/** Revert from preview back to the persisted theme */
revertPreview: () => void;
/** Check if currently in preview mode */
isPreviewMode: () => boolean;
/** Get the persisted theme ID (ignores preview) */
getPersistedThemeId: () => string;
}
For larger applications, you may want to organize themes with metadata. Theme catalogs support:
import type { ThemeCatalog } from 'svelte-theme-picker';
const myCatalog: ThemeCatalog = {
'brand-dark': {
theme: { /* theme definition */ },
meta: {
active: true, // Show in picker
tags: ['dark', 'brand'],
order: 1, // Sort order
seasonal: false, // Or 'winter', 'spring', etc.
}
},
'christmas': {
theme: { /* theme definition */ },
meta: {
active: false, // Hidden from picker
tags: ['dark', 'seasonal'],
seasonal: 'winter',
}
},
'test-theme': {
theme: { /* theme definition */ },
meta: {
active: false, // Dev only
tags: ['test'],
}
},
};
import {
defaultThemeCatalog,
filterCatalog,
catalogToThemes,
getActiveThemes,
getThemesByTag,
getThemesByAnyTag,
} from 'svelte-theme-picker';
// Get only active themes
const activeThemes = getActiveThemes(defaultThemeCatalog);
// Get themes by tag
const darkThemes = getThemesByTag(defaultThemeCatalog, 'dark');
// Get themes by any of several tags
const accentThemes = getThemesByAnyTag(defaultThemeCatalog, ['neon', 'vibrant']);
// Advanced filtering
const productionThemes = catalogToThemes(
filterCatalog(defaultThemeCatalog, {
activeOnly: true,
excludeTags: ['test', 'seasonal'],
})
);
// Use with ThemePicker
<ThemePicker config={{ themes: productionThemes }} />
Define your themes in a JSON file for easy management and version control:
themes.json
{
"brand-dark": {
"theme": {
"name": "Brand Dark",
"description": "Official dark theme",
"colors": {
"bgDeep": "#0a0a12",
"bgMid": "#12121a",
"bgCard": "#1a1a24",
"bgGlow": "#2a2a3a",
"bgOverlay": "#0a0a12",
"primary1": "#6366f1",
"primary2": "#818cf8",
"primary3": "#a5b4fc",
"primary4": "#c7d2fe",
"primary5": "#e0e7ff",
"primary6": "#eef2ff",
"accent1": "#8b5cf6",
"accent2": "#a78bfa",
"accent3": "#c4b5fd",
"textPrimary": "#f8fafc",
"textSecondary": "#cbd5e1",
"textMuted": "#64748b"
},
"fonts": {
"heading": "'Inter', sans-serif",
"body": "'Inter', sans-serif",
"mono": "'JetBrains Mono', monospace"
},
"effects": {
"glowColor": "rgba(99, 102, 241, 0.15)",
"glowIntensity": 0.3,
"particleColors": ["#6366f1", "#8b5cf6"],
"useNoise": false,
"noiseOpacity": 0
}
},
"meta": {
"active": true,
"tags": ["dark", "brand", "production"],
"order": 1
}
},
"christmas-special": {
"theme": {
"name": "Christmas",
"description": "Festive holiday theme"
},
"meta": {
"active": false,
"tags": ["dark", "seasonal", "holiday"],
"seasonal": "winter"
}
},
"dev-test": {
"theme": {
"name": "Dev Test",
"description": "Testing theme - not for production"
},
"meta": {
"active": false,
"tags": ["test", "dev"]
}
}
}
Loading and filtering for production:
import {
loadCatalogFromJSON,
getActiveThemes,
filterCatalog,
catalogToThemes,
} from 'svelte-theme-picker';
// Load themes from JSON (e.g., fetched or imported)
import themesJson from './themes.json';
const catalog = loadCatalogFromJSON(themesJson);
// Get only production-ready themes (active: true)
const productionThemes = getActiveThemes(catalog);
// Or with more advanced filtering
const productionThemes = catalogToThemes(
filterCatalog(catalog, {
activeOnly: true,
excludeTags: ['test', 'seasonal'],
})
);
// Use with ThemePicker
<ThemePicker config={{ themes: productionThemes }} />
This pattern lets you:
active flag// Convert simple themes to catalog
import { themesToCatalog } from 'svelte-theme-picker';
const catalog = themesToCatalog(myThemes, { active: true });
// Merge catalogs (combine default themes with your custom ones)
import { mergeCatalogs, defaultThemeCatalog } from 'svelte-theme-picker';
const combined = mergeCatalogs(defaultThemeCatalog, myCatalog);
// Get all tags in a catalog
import { getCatalogTags } from 'svelte-theme-picker';
const tags = getCatalogTags(myCatalog); // ['dark', 'brand', 'seasonal', ...]
// Sort catalog by order
import { sortCatalog } from 'svelte-theme-picker';
const sorted = sortCatalog(myCatalog);
For complete control over the theme picker UI (e.g., in forms), you can build your own using the exported utilities. This is useful when you need color swatches in a specific layout or want to match your app's design system.
<script lang="ts">
import {
themeStore,
applyTheme,
defaultThemes,
type Theme
} from 'svelte-theme-picker';
import { onMount } from 'svelte';
let selectedTheme = $state('dreamy');
// Revert to saved theme when leaving the page
onMount(() => {
return () => themeStore.revertPreview();
});
function previewTheme(themeId: string) {
selectedTheme = themeId;
themeStore.previewTheme(themeId);
applyTheme(defaultThemes[themeId]);
}
function saveSelection() {
// Persist the theme when form is submitted
themeStore.setTheme(selectedTheme);
}
</script>
<div class="theme-grid">
{#each Object.entries(defaultThemes) as [id, theme]}
<button
type="button"
class="theme-swatch"
class:selected={selectedTheme === id}
onclick={() => previewTheme(id)}
title={theme.name}
>
<div class="colors">
<span style="background: {theme.colors.primary1}"></span>
<span style="background: {theme.colors.primary3}"></span>
<span style="background: {theme.colors.primary5}"></span>
<span style="background: {theme.colors.accent1}"></span>
</div>
<span class="name">{theme.name}</span>
</button>
{/each}
</div>
<style>
.theme-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.theme-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
background: transparent;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.theme-swatch:hover {
background: rgba(255, 255, 255, 0.05);
}
.theme-swatch.selected {
border-color: var(--accent-1, #a855f7);
background: rgba(168, 85, 247, 0.1);
}
.colors {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2px;
width: 32px;
height: 32px;
border-radius: 6px;
overflow: hidden;
}
.colors span {
width: 100%;
height: 100%;
}
.name {
font-size: 0.75rem;
color: var(--text-secondary, #a0a0a0);
}
.theme-swatch.selected .name {
color: var(--text-primary, #fff);
}
</style>
When integrating theme selection in a form (e.g., collection creation), use the preview pattern:
getPersistedThemeId()previewTheme() + applyTheme() for live previewrevertPreview() to restore the original themesetTheme() to persist the selection<script>
import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
import { onMount } from 'svelte';
let selectedTheme = 'dreamy';
let formSubmitted = false;
onMount(() => {
// Restore original theme if user leaves without saving
return () => {
if (!formSubmitted) {
themeStore.revertPreview();
}
};
});
function selectTheme(themeId) {
selectedTheme = themeId;
themeStore.previewTheme(themeId);
applyTheme(defaultThemes[themeId]);
}
function handleSubmit() {
formSubmitted = true;
themeStore.setTheme(selectedTheme);
// ... save to database
}
</script>
You can provide your own themes:
<script>
import { ThemePicker, type Theme } from 'svelte-theme-picker';
const myThemes: Record<string, Theme> = {
'my-theme': {
name: 'My Theme',
description: 'A custom theme',
colors: {
bgDeep: '#1a1a2e',
bgMid: '#232342',
bgCard: '#2a2a4a',
bgGlow: '#3d3d6b',
bgOverlay: '#1a1a2e',
primary1: '#c9a0dc',
primary2: '#b8a9d9',
primary3: '#e8a4c9',
primary4: '#7eb8da',
primary5: '#8ad4d4',
primary6: '#f0c4a8',
accent1: '#a855f7',
accent2: '#ff6b9d',
accent3: '#64c8ff',
textPrimary: '#e8e0f0',
textSecondary: '#c8c0d8',
textMuted: '#9090b0',
},
fonts: {
heading: "'Inter', sans-serif",
body: "'Inter', sans-serif",
mono: "'JetBrains Mono', monospace",
},
effects: {
glowColor: 'rgba(168, 85, 247, 0.15)',
glowIntensity: 0.3,
particleColors: ['#c9a0dc', '#a855f7'],
useNoise: false,
noiseOpacity: 0,
},
},
};
</script>
<ThemePicker config={{ themes: myThemes, defaultTheme: 'my-theme' }} />
The ThemePicker component can be customized using CSS custom properties. The picker automatically uses your theme's CSS variables as fallbacks, so it adapts to your theme.
Override these to customize the picker appearance:
/* In your global CSS or :root */
:root {
/* Colors */
--stp-bg: var(--bg-card); /* Panel background */
--stp-bg-hover: var(--bg-glow); /* Hover state background */
--stp-bg-active: ...; /* Active/selected item */
--stp-border: ...; /* Border color */
--stp-text: var(--text-primary); /* Primary text */
--stp-text-muted: var(--text-muted);/* Secondary text */
--stp-accent: var(--accent-1); /* Accent color */
--stp-accent-glow: ...; /* Glow effect color */
/* Trigger button */
--stp-trigger-bg: ...; /* Trigger background gradient */
--stp-trigger-color: var(--bg-deep);/* Trigger icon color */
/* Layout */
--stp-radius: 12px; /* Panel border radius */
--stp-radius-sm: 8px; /* Item border radius */
--stp-space: 12px; /* Standard spacing */
--stp-space-sm: 8px; /* Small spacing */
--stp-transition: 0.2s ease; /* Transition timing */
}
The picker uses color-mix() for calculated colors (active states, borders) that automatically adjust to your theme. No !important overrides should be needed.
Themes can declare a mode property ('light' or 'dark') to help the picker adjust its styling:
const myTheme: Theme = {
name: 'My Light Theme',
description: 'A light theme',
mode: 'light', // or 'dark'
colors: { ... },
fonts: { ... },
effects: { ... },
};
The theme picker applies these CSS variables to your document:
--bg-deep - Deepest background--bg-mid - Mid-level background--bg-card - Card/surface background--bg-glow - Glow/highlight background--bg-overlay - Overlay background--primary-1 through --primary-6--accent-1 through --accent-3--text-primary--text-secondary--text-muted--font-heading--font-body--font-mono--shadow-glow--glow-color--glow-intensity<script>
import { themeStore, applyTheme, defaultThemes } from 'svelte-theme-picker';
// Subscribe to changes
$effect(() => {
const theme = defaultThemes[$themeStore];
if (theme) {
console.log('Theme changed to:', theme.name);
}
});
// Change theme programmatically
function setDarkMode() {
themeStore.setTheme('mono');
}
</script>
| ID | Name | Tags | Description |
|---|---|---|---|
dreamy |
Dreamy | dark, pastel | Soft pastels with dreamy atmosphere |
cyberpunk |
Cyberpunk | dark, neon | High contrast neons |
sunset |
Sunset | dark, warm | Warm oranges and purples |
ocean |
Ocean | dark, cool | Deep blues and teals |
mono |
Mono | dark, minimal | Clean monochromatic |
sakura |
Sakura | dark, pastel | Cherry blossom pinks |
aurora |
Aurora | dark, nature | Northern lights greens and purples |
galaxy |
Galaxy | dark, space | Deep space cosmic colors |
milk |
Milk | light, neutral | Clean, creamy light theme |
light |
Light | light, modern | Modern light theme with purple accents |
The theme picker is optimized to minimize impact on your application:
contain: layout style to isolate renderingtransform and will-change for smooth 60fps transitionsrequestAnimationFrame to batch all CSS variable updates into a single paint frametransition: allThe component follows accessibility best practices:
Escape to close the theme panelaria-label attributesrole="listbox" with role="option" itemsaria-expanded and aria-haspopup on trigger buttonaria-hidden="true"-webkit-backdrop-filter for blur effectsFull TypeScript support with exported types:
import type {
// Theme types
Theme,
ThemeColors,
ThemeFonts,
ThemeEffects,
ThemePickerConfig,
// Catalog types
ThemeCatalog,
ThemeCatalogEntry,
ThemeMeta,
ThemeFilterOptions,
// SSR types
SSRConfig,
FontConfig,
ThemeSchema,
} from 'svelte-theme-picker';
Upgrading from a previous version? See MIGRATION.md for breaking changes and migration steps.
MIT