Companion demo for: Theme System with Context on The Hackpile Chronicles
A production-ready theme system built with Svelte 5 runes and SvelteKit, demonstrating modern context patterns, SSR-safe state management, and nested theme overrides.
$state, $derived)# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build for production
pnpm build
src/lib/theme/
├── types.ts # Theme types
├── theme-context.svelte.ts # Context logic with runes
├── ThemeProvider.svelte # Root provider component
├── ThemeSelector.svelte # Theme picker UI
└── ThemeToggle.svelte # Quick toggle button
theme-context.svelte.ts)The heart of the system—manages theme state using Svelte 5 runes:
export function createThemeContext(options?: CreateThemeOptions): ThemeContext {
let systemMode = $state<ResolvedTheme>(getSystemPreference());
let preference = $state<ThemePreference>('system');
let mode = $derived.by<ResolvedTheme>(() => {
const forced = getForceTheme();
if (forced) return forced;
return preference === 'system' ? systemMode : preference;
});
return {
get mode() {
return mode;
},
get preference() {
return preference;
},
setPreference(theme) {
/* ... */
},
toggle() {
/* ... */
}
// ...
};
}
Key features:
$state for reactive preference tracking$derived for computed theme resolutionmatchMedia listenerPrevents hydration mismatches with inline script:
<script>
// Runs before hydration
const preference = localStorage.getItem('theme-preference') || 'system';
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = preference === 'system' ? (systemDark ? 'dark' : 'light') : preference;
document.documentElement.setAttribute('data-theme', theme);
</script>
Force specific themes in component subtrees:
<ThemeProvider forceTheme="dark">
<!-- Always renders in dark mode -->
<Card>Dark card content</Card>
</ThemeProvider>
<!-- +layout.svelte -->
<script>
import ThemeProvider from '$lib/theme/ThemeProvider.svelte';
</script>
<ThemeProvider>
<slot />
</ThemeProvider>
<script>
import { getThemeContext } from '$lib/theme/theme-context.svelte';
const theme = getThemeContext();
</script>
<button onclick={() => theme.toggle()}>
Current: {theme.mode} (preference: {theme.preference})
</button>
<script>
import ThemeToggle from '$lib/theme/ThemeToggle.svelte';
import ThemeSelector from '$lib/theme/ThemeSelector.svelte';
</script>
<!-- Quick toggle button -->
<ThemeToggle />
<!-- Full theme selector with icons -->
<ThemeSelector />
createThemeContext(options?)Creates a theme context instance.
Options:
forceTheme?: ResolvedTheme | (() => ResolvedTheme) - Force a specific themeReturns: ThemeContext with:
mode: ResolvedTheme - Current resolved theme ('light' or 'dark')preference: ThemePreference - User preference ('light', 'dark', or 'system')isDark: boolean - Whether current mode is darkisLight: boolean - Whether current mode is lightisSystem: boolean - Whether preference is 'system'setPreference(theme: ThemePreference): void - Update preferencetoggle(): void - Toggle between light and darkreset(): void - Reset to system default<ThemeProvider>Root component that initializes theme context.
Props:
forceTheme?: ResolvedTheme - Force a specific theme for nested overridetype ThemePreference = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
Themes are applied via CSS custom properties on [data-theme]:
:root[data-theme='light'] {
--color-background: #ffffff;
--color-text: #1a1a1a;
}
:root[data-theme='dark'] {
--color-background: #1a1a1a;
--color-text: #ffffff;
}
Components automatically inherit theme via CSS variables.
Read the full article: Theme System with Context on The Hackpile Chronicles
Topics covered:
This is a companion demo—feel free to fork and experiment!
MIT