The Purest Use Case for Context
Theme management is one of the purest use cases for context. A theme affects every visual component in an application, needs to change dynamically, and shouldn’t require prop drilling through dozens of components. Context gives you exactly what theming needs: ambient data that any descendant can access without explicit wiring.
Consider the Simpler Alternative FirstIf you don’t need nested theme overrides—different parts of your component tree using different themes simultaneously—consider the simpler useTheme hook pattern instead. It uses module-level reactive state, requires no providers, and covers 95% of use cases in under 100 lines of code. This article covers the Context approach for when you specifically need nested providers.
By the end of this article, you’ll have a theme system that supports light, dark, and system-preference modes, automatically detects and responds to operating system theme changes, persists user preferences to localStorage, loads without any “flash of wrong theme,” works correctly with server-side rendering, and supports local theme overrides through nested providers.
The key architectural insight: CSS is the single source of truth for colors. JavaScript only manages which theme is active by setting the data-theme attribute. The browser handles the rest natively and efficiently.
The Problem Space
Before writing code, consider what makes theming genuinely difficult. The obvious approach—passing a theme prop through every component—fails immediately in any real application. With navigation, sidebars, cards, buttons, forms, modals, and tooltips all needing theme information, you’d be threading the same prop through every single component in your tree.
Context solves the distribution problem, but theming has additional complications. First, there’s the preference versus resolution distinction. When a user selects “system” as their theme preference, components need an actual theme—light or dark—to render. Something must resolve the preference into a concrete theme.
Second, the system preference isn’t static. Users change their system theme while your app is running. Your theme system must listen for these changes and respond in real-time.
Third, server-side rendering creates a timing problem. The server renders HTML without knowing the user’s preference. By the time JavaScript loads and reads localStorage, the browser has already painted the page with the wrong theme. Users see a jarring flash.
Fourth, nested overrides are a legitimate requirement. A dark-themed promotional card on a light page requires different parts of the component tree to use different themes simultaneously. This is the primary reason to use Context over module-level state.
What Makes a Good Theme System?
Before diving into implementation, let’s establish clear requirements. A production-quality theme system should meet these criteria:
Functional Requirements
| Requirement | Description |
|---|---|
| Multiple modes | Support at least light and dark themes |
| System preference | Detect OS-level color scheme preference |
| User override | Allow users to choose their preferred theme |
| Persistence | Remember user choice across sessions |
| Real-time sync | Respond when system preference changes |
| Easy component access | Simple API for any component to read theme |
User Experience Requirements
| Requirement | Description |
|---|---|
| No flash | Page loads with correct theme immediately |
| Smooth transitions | Theme changes animate gracefully |
| Consistent | All components reflect theme simultaneously |
| Accessible | Theme controls have proper ARIA attributes |
Developer Experience Requirements
| Requirement | Description |
|---|---|
| Type-safe | Full TypeScript support with inference |
| Encapsulated | Implementation details hidden from consumers |
| Composable | Works with CSS variables, Tailwind, or direct access |
| SSR-compatible | Works with SvelteKit’s server rendering |
With these requirements in mind, let’s design our solution.
Theme Architecture Overview
The key insight: CSS handles all color definitions and switching. JavaScript’s only job is to set the data-theme attribute. This is simpler, faster, and what all production libraries use.
The Three-Mode Model
Most applications need three theme settings: Light (always use light theme), Dark (always use dark theme), and System (follow the operating system’s preference).
Components never handle the 'system' case themselves—they always receive a definitive 'light' or 'dark'.
CSS: The Single Source of Truth
Define all theme colors in your global CSS. This is the only place colors are defined:
/* src/app.css */
/* Light theme (default) */
:root {
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-surface-hover: #f1f5f9;
--color-foreground: #0f172a;
--color-foreground-muted: #475569;
--color-foreground-subtle: #94a3b8;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-foreground: #ffffff;
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-error: #dc2626;
--color-info: #0284c7;
--color-border: #e2e8f0;
--color-border-focus: #3b82f6;
--color-shadow: rgba(0, 0, 0, 0.1);
}
/* Dark theme */
:root[data-theme='dark'] {
--color-background: #0f172a;
--color-surface: #1e293b;
--color-surface-hover: #334155;
--color-foreground: #f8fafc;
--color-foreground-muted: #cbd5e1;
--color-foreground-subtle: #64748b;
--color-primary: #3b82f6;
--color-primary-hover: #60a5fa;
--color-primary-foreground: #ffffff;
--color-success: #22c55e;
--color-warning: #eab308;
--color-error: #ef4444;
--color-info: #0ea5e9;
--color-border: #334155;
--color-border-focus: #60a5fa;
--color-shadow: rgba(0, 0, 0, 0.4);
}
/* Base styles */
html,
body {
margin: 0;
background-color: var(--color-background);
color: var(--color-foreground);
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.5;
transition:
background-color 0.3s ease,
color 0.3s ease;
} Why CSS is BetterSingle source of truth — Colors defined once, no duplication in TypeScript objects.
Browser-native performance — The browser handles CSS variable switching natively—no JavaScript loops or
setPropertycalls.DevTools friendly — See all variables in the Styles panel, easy to debug.
Works before JavaScript — With proper SSR, themes work even if JS fails to load.
When JavaScript-Based CSS Properties ARE Needed
The CSS-first approach works for 95% of applications. However, there are legitimate cases where you need JavaScript to set CSS custom properties dynamically:
| Use Case | Why JavaScript Is Needed |
|---|---|
| User-customizable colors | Color picker where users choose their own palette—colors aren’t known at build time |
| API-driven themes | Colors fetched from database, CMS, or brand management system |
| Runtime computation | Generating tints, shades, or color scales programmatically (e.g., chroma.js) |
| Third-party integration | Libraries that need color values in JS (Chart.js, D3, Canvas APIs) |
| White-label products | SaaS apps where each tenant has custom branding loaded at runtime |
Rule of ThumbIf colors are known at build time → Define them in CSS.
If colors are determined at runtime → Use JavaScript to set them.
Most apps have static color palettes, so CSS-first is the default choice.
For detailed implementations of each use case—including color pickers, API-driven themes, Chroma.js palette generation, Chart.js integration, and white-label SaaS patterns—see the dedicated article: Dynamic Theme Customization: Runtime CSS Properties.
Building the Theme Context
Now let’s build the context. Notice how simple it is—JavaScript only manages state and sets the data-theme attribute:
// src/lib/theme/types.ts
/** User-facing theme options */
export type ThemePreference = 'light' | 'dark' | 'system'
/** Actual rendered theme after resolving 'system' */
export type ResolvedTheme = 'light' | 'dark'
/** Theme context shape exposed to consumers */
export interface ThemeContext {
readonly mode: ResolvedTheme
readonly preference: ThemePreference
readonly isDark: boolean
readonly isLight: boolean
readonly isSystem: boolean
setPreference: (theme: ThemePreference) => void
toggle: () => void
reset: () => void
} // src/lib/theme/theme-context.svelte.ts
import { setContext, getContext, hasContext } from 'svelte'
import { browser } from '$app/environment'
import type { ThemePreference, ResolvedTheme, ThemeContext } from './types.js'
const THEME_KEY = Symbol('theme')
const STORAGE_KEY = 'theme-preference'
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
function getSystemPreference(): ResolvedTheme {
if (!browser) return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function loadSavedPreference(): ThemePreference | null {
if (!browser) return null
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'light' || saved === 'dark' || saved === 'system') {
return saved
}
return null
}
interface CreateThemeOptions {
/** Force a specific theme (for nested overrides) */
forceTheme?: ResolvedTheme
}
/**
* Creates the theme context. Call once in root layout,
* or nested for local theme overrides.
*/
export function createThemeContext(options: CreateThemeOptions = {}): ThemeContext {
const { forceTheme } = options
// State
let systemMode = $state<ResolvedTheme>(getSystemPreference())
let preference = $state<ThemePreference>(loadSavedPreference() ?? 'system')
// Derived values
let mode = $derived.by<ResolvedTheme>(() => {
if (forceTheme) return forceTheme
if (preference === 'system') return systemMode
return preference
})
let isDark = $derived(mode === 'dark')
let isLight = $derived(mode === 'light')
let isSystem = $derived(preference === 'system')
// Effect: Listen for system preference changes
$effect(() => {
if (!browser) return
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function handleChange(event: MediaQueryListEvent) {
systemMode = event.matches ? 'dark' : 'light'
}
systemMode = mediaQuery.matches ? 'dark' : 'light'
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
})
// Effect: Apply theme (skip for nested providers)
$effect(() => {
if (!browser || forceTheme) return
// This is ALL JavaScript needs to do!
document.documentElement.dataset.theme = mode
document.documentElement.style.colorScheme = mode
})
// Effect: Persist preference (skip for nested providers)
$effect(() => {
if (!browser || forceTheme) return
localStorage.setItem(STORAGE_KEY, preference)
document.cookie = `${STORAGE_KEY}=${preference};path=/;max-age=${COOKIE_MAX_AGE};SameSite=Lax`
})
// Effect: Update meta theme-color for mobile browsers
$effect(() => {
if (!browser || forceTheme) return
let meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
document.head.appendChild(meta)
}
// Read the CSS variable value
const bg = getComputedStyle(document.documentElement)
.getPropertyValue('--color-background')
.trim()
meta.setAttribute('content', bg)
})
// Build context with getters for reactivity
const context: ThemeContext = {
get mode() {
return mode
},
get preference() {
return preference
},
get isDark() {
return isDark
},
get isLight() {
return isLight
},
get isSystem() {
return isSystem
},
setPreference(pref: ThemePreference) {
preference = pref
},
toggle() {
if (preference === 'system') {
preference = mode === 'dark' ? 'light' : 'dark'
} else {
preference = preference === 'dark' ? 'light' : 'dark'
}
},
reset() {
preference = 'system'
}
}
return setContext(THEME_KEY, context)
}
export function getThemeContext(): ThemeContext {
if (!hasContext(THEME_KEY)) {
throw new Error('Theme context not found. Wrap your app in ThemeProvider.')
}
return getContext(THEME_KEY)
}
export function hasThemeContext(): boolean {
return hasContext(THEME_KEY)
} Notice what’s not here:
- No
ThemeColorstype - No
lightColors/darkColorsobjects - No
applyCSSPropertiesfunction - No loops setting CSS properties
JavaScript just sets data-theme and CSS handles everything else.
Creating the Provider Component
<!-- src/lib/theme/ThemeProvider.svelte -->
<script lang="ts">
import { createThemeContext } from './theme-context.svelte.js'
import type { ResolvedTheme } from './types.js'
import type { Snippet } from 'svelte'
interface Props {
/** Force a specific theme (for nested overrides) */
forceTheme?: ResolvedTheme
children: Snippet
}
let { forceTheme, children }: Props = $props()
const theme = createThemeContext({ forceTheme })
</script>
<!-- For nested providers, set data-theme on the wrapper -->
{#if forceTheme}
<div class="theme-scope" data-theme={theme.mode}>
{@render children()}
</div>
{:else}
{@render children()}
{/if}
<style>
.theme-scope {
background-color: var(--color-background);
color: var(--color-foreground);
transition:
background-color 0.3s ease,
color 0.3s ease;
}
</style> Using the Provider
<!-- src/routes/+layout.svelte -->
<script>
import ThemeProvider from '$lib/theme/ThemeProvider.svelte'
import '../app.css'
let { children } = $props()
</script>
<ThemeProvider>
{@render children()}
</ThemeProvider> Nested Theme Overrides
This is the main reason to use Context over module-level state. Context’s scoping allows nested providers:
<!-- src/routes/+page.svelte -->
<script>
import ThemeProvider from '$lib/theme/ThemeProvider.svelte'
</script>
<!-- Page uses global theme (light) -->
<div class="page">
<h1>Welcome</h1>
<!-- This section forces dark theme -->
<ThemeProvider forceTheme="dark">
<section class="promo-card">
<h2>Featured Content</h2>
<p>This card is always dark, even on a light page.</p>
</section>
</ThemeProvider>
<!-- This follows the page theme -->
<section class="regular-card">
<h2>Regular Content</h2>
<p>This card follows the page theme.</p>
</section>
</div>
<style>
.promo-card,
.regular-card {
background-color: var(--color-surface);
color: var(--color-foreground);
border: 1px solid var(--color-border);
padding: 1.5rem;
border-radius: 12px;
margin: 1rem 0;
}
</style> The nested ThemeProvider sets data-theme="dark" on its wrapper div. CSS custom properties cascade, so everything inside uses dark colors while everything outside stays light.
Preventing Flash of Wrong Theme
On the server, we don’t have localStorage or matchMedia. The theme will flash as it corrects itself during hydration. To fix this we can add an inline script to app.html header that runs before the page renders, setting the correct theme immediately:
<!-- src/app.html -->
<!doctype html>
<html lang="en" data-theme="">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- CRITICAL: Runs before page renders -->
<script>
;(function () {
try {
const stored = localStorage.getItem('theme-preference')
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
let theme
if (stored === 'dark' || stored === 'light') {
theme = stored
} else {
theme = systemDark ? 'dark' : 'light'
}
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
} catch (e) {
// localStorage might be blocked
}
})()
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> Because his script runs synchronously before the page paints anything, users will see the correct theme immediately with no flash.
Optional: Cookie-Based SSR
There is one caveat: If a user has JavaScript disabled, the inline script won’t run and the theme will default to light. To support this edge case, we can read the theme from a cookie on the server and inject it into the rendered HTML:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
const theme = event.cookies.get('theme-preference')
if (!theme || !['light', 'dark'].includes(theme)) {
return resolve(event)
}
return resolve(event, {
transformPageChunk: ({ html }) => {
return html.replace('data-theme=""', `data-theme="${theme}"`)
}
})
} Now even users without JavaScript will see their preferred theme on the first paint.
Building Theme Controls
Simple Toggle Button
<!-- src/lib/theme/ThemeToggle.svelte -->
<script lang="ts">
import { getThemeContext } from './theme-context.svelte.js'
const theme = getThemeContext()
let label = $derived(theme.isDark ? 'Switch to light mode' : 'Switch to dark mode')
</script>
<button type="button" onclick={() => theme.toggle()} class="theme-toggle" aria-label={label}>
{#if theme.isDark}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
{/if}
</button>
<style>
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: 50%;
background-color: var(--color-surface);
color: var(--color-foreground);
cursor: pointer;
transition:
background-color 0.2s,
transform 0.2s;
}
.theme-toggle:hover {
background-color: var(--color-surface-hover);
transform: scale(1.05);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--color-border-focus);
outline-offset: 2px;
}
.theme-toggle svg {
width: 22px;
height: 22px;
}
</style> Three-Way Selector
<!-- src/lib/theme/ThemeSelector.svelte -->
<script lang="ts">
import { getThemeContext } from './theme-context.svelte.js'
import type { ThemePreference } from './types.js'
const theme = getThemeContext()
const options: { value: ThemePreference; label: string; icon: string }[] = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'system', label: 'System', icon: '💻' }
]
</script>
<div class="theme-selector" role="radiogroup" aria-label="Theme selection">
{#each options as option (option.value)}
<button
type="button"
role="radio"
aria-checked={theme.preference === option.value}
class="option"
class:selected={theme.preference === option.value}
onclick={() => theme.setPreference(option.value)}
>
<span class="icon">{option.icon}</span>
<span class="label">{option.label}</span>
</button>
{/each}
</div>
{#if theme.isSystem}
<p class="system-note">
Currently using <strong>{theme.mode}</strong> based on system settings.
</p>
{/if}
<style>
.theme-selector {
display: inline-flex;
gap: 4px;
padding: 4px;
border-radius: 12px;
background: var(--color-surface);
}
.option {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-foreground);
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.option:hover:not(.selected) {
background: var(--color-surface-hover);
}
.option.selected {
background: var(--color-background);
box-shadow: 0 1px 3px var(--color-shadow);
}
.icon {
font-size: 16px;
}
.system-note {
margin-top: 8px;
font-size: 12px;
color: var(--color-foreground-muted);
}
</style> Using Themes in Components
Method 1: CSS Variables (Recommended)
Most components should just use CSS variables—no context import needed:
<!-- src/lib/components/Card.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
title: string
children: Snippet
}
let { title, children }: Props = $props()
</script>
<article class="card">
<h2>{title}</h2>
<div class="content">{@render children()}</div>
</article>
<style>
.card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.5rem;
transition:
background-color 0.3s,
border-color 0.3s;
}
.card:hover {
background-color: var(--color-surface-hover);
}
h2 {
margin: 0 0 1rem;
color: var(--color-foreground);
}
.content {
color: var(--color-foreground-muted);
}
</style> This component automatically adapts to any theme without importing context.
Method 2: Context for Programmatic Access
Only use context when you need programmatic access (canvas, SVG, third-party libraries):
<!-- src/lib/components/Chart.svelte -->
<script lang="ts">
import { getThemeContext } from '$lib/theme/theme-context.svelte.js'
const theme = getThemeContext()
let { data }: { data: number[] } = $props()
let canvas = $state<HTMLCanvasElement | null>(null)
$effect(() => {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// Read CSS variables for colors
const styles = getComputedStyle(document.documentElement)
const surface = styles.getPropertyValue('--color-surface').trim()
const primary = styles.getPropertyValue('--color-primary').trim()
ctx.fillStyle = surface
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.strokeStyle = primary
ctx.lineWidth = 2
ctx.beginPath()
data.forEach((point, i) => {
const x = (i / data.length) * canvas.width
const y = canvas.height - (point / 100) * canvas.height
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.stroke()
})
</script>
<canvas bind:this={canvas} width="400" height="200"></canvas> Common Mistakes
1. Setting CSS properties via JavaScript
In 95% of cases, setting CSS properties via JavaScript is unnecessary and inefficient. You will see when to use for these 5% it in next article.
// Don't do this - let CSS handle it
root.style.setProperty('--color-background', colors.background)
root.style.setProperty('--color-surface', colors.surface)
// ... 15 more lines Just set the attribute directly:
// CSS selectors handle the rest
document.documentElement.dataset.theme = mode 2. Importing context in every component
You rarely need programmatic access to theme data.
<script>
// Don't do this everywhere
const theme = getThemeContext()
const bg = theme.isDark ? '#0f172a' : '#ffffff'
</script> Use CSS variables instead:
<style>
.card {
background: var(--color-surface);
}
</style> 3. Duplicating colors in TypeScript
Don’t maintain two sources of truth.
export const darkColors = {
background: '#0f172a'
// ... same colors as CSS
} CSS should be your single source of truth.
:root[data-theme='dark'] {
--color-background: #0f172a;
} Project Structure
src/
├── lib/
│ └── theme/
│ ├── types.ts # TypeScript interfaces (minimal)
│ ├── theme-context.svelte.ts # Context management
│ ├── ThemeProvider.svelte # Provider component
│ ├── ThemeToggle.svelte # Toggle button
│ ├── ThemeSelector.svelte # Three-way selector
│ └── index.ts # Public exports
├── routes/
│ └── +layout.svelte # Wraps app in ThemeProvider
├── hooks.server.ts # Optional: Cookie-based SSR
├── app.html # Flash prevention script
└── app.css # ALL theme colors defined here Performance Considerations
The CSS-first approach is highly performant:
- Native browser optimization — CSS variable switching is handled internally by the browser, not JavaScript
- No JavaScript loops — We don’t iterate over colors or call
setProperty - Single reflow — Setting
data-themetriggers one style recalculation - DevTools friendly — Easy to inspect and debug in Styles panel
Most components should use CSS variables exclusively. Reserve getThemeContext() for components that genuinely need programmatic access.
When NOT to Use Context
Context adds complexity. Consider simpler alternatives:
| Scenario | Recommendation |
|---|---|
| Global theme only (no nested overrides) | Use useTheme hook pattern |
| Static theme (no user preference) | Just CSS variables, no JavaScript |
| Component library | Expose theming through CSS variables, not context |
| Micro-frontends | Use shadow DOM or CSS containment |
Use Context when you specifically need nested theme overrides—that’s its unique capability.
Conclusion
A production theme system with Context is simpler than it might seem when you follow the CSS-first approach:
- CSS is the single source of truth — All colors defined in
app.cssusing:root[data-theme]selectors - JavaScript just sets an attribute —
document.documentElement.dataset.theme = mode - Components use CSS variables — No context imports needed for most components
- Context enables nested overrides — The main reason to use Context over module-level state
The result is less code, better performance, and easier debugging.
Key Takeaways
CSS handles colors — Define themes with
:root[data-theme]selectors, not JavaScript objects.JavaScript sets one attribute —
data-themeis all you need. NosetPropertyloops.Most components need no context — Just use
var(--color-surface)in CSS.Context is for nested overrides — That’s its unique value over module-level state.
Prevent flash with inline script — Runs before render, sets
data-themeimmediately.Getters maintain reactivity — Return
get mode()notmodefrom context.
Further Reading
Related Articles
- Dynamic Theme Customization: Runtime CSS Properties — Color pickers, API-driven themes, Chroma.js, Chart.js, and white-label SaaS patterns
- Simple Theme Switching with useTheme Hook — Simpler approach without Context
- Reactive Context Fundamentals — Deep dive into reactive context patterns
Official Documentation
- Svelte Context — Official guide to context API
- CSS Custom Properties — MDN reference
- prefers-color-scheme — System theme detection