Building a Theme Switcher
It's easy to see that almost every website out there these days employs a theme switcher. But not just any switcher – we need one that's smart enough to remember what users want, smooth enough to avoid flashy transitions, and respectful enough to follow system preferences unless told otherwise. Today, we're going to build exactly that
What We’re Building (And Why It Matters)
Imagine you’re building a house with smart lighting. You’d want:
- Lights that remember your preferred settings
- Switches that work instantly
- Sensors that detect when it’s dark outside
- A system that doesn’t blind you when you walk in at night
That’s exactly what we’re building for our website, except instead of physical lights, we’re dealing with CSS themes. And like any good architect, we’ll start with a blueprint.
The Strategy (Or: How We’re Going to Pull This Off)
Here’s our battle plan:
- Create a Svelte store (our control center)
- Build a system to detect and remember preferences
- Handle theme transitions smoothly
- Create a delightful UI component
- Make everything play nice with server-side rendering
But before we dive in, let’s talk about why we’re making certain choices:
Why Svelte Store?
We could use plain JavaScript with local storage, but a Svelte store gives us:
- Reactive updates across components
- Built-in subscription management
- Clean integration with Svelte’s reactivity system
Why Local Storage?
We could use cookies, but local storage:
- Persists longer
- Doesn’t get sent with every HTTP request
- Is easier to work with on the client side
Why Media Queries?
Instead of just defaulting to light mode, we respect the user’s system preferences because:
- It’s more accessible
- It shows we care about user experience
- It follows the principle of least surprise
The Implementation: Step by Step
Step 1: Setting Up Our Store
First, let’s create src/lib/stores/theme.svelte.ts
:
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
type Theme = 'light' | 'dark';
const createThemeStore = () => {
// ... we'll fill this in next
};
This is our foundation. The Theme
type makes sure we can’t accidentally set invalid themes. Think of it as putting child locks on light switches – you can only flip them to valid positions.
Step 2: The Initial Theme Detective Work
const getInitialTheme = () => {
if (browser) {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
return savedTheme as Theme;
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
}
return 'light';
};
This function is like a detective following a strict protocol. Think of it as a flow chart at a fancy hotel:
- Is there a “Do Not Disturb” sign (saved preference)? Honor it.
- No sign? Check if it’s night time (system preference).
- Still unsure? Turn on the lights (default to light theme).
But why this order? It’s all about user autonomy. A manual preference should always trump an automatic one. It’s like having both a light switch and motion sensors in your bathroom – if you explicitly turn the lights off, the motion sensor shouldn’t override your decision.
Step 3: The Theme Store Constructor
const createThemeStore = () => {
const { subscribe, set, update } = writable<Theme>(getInitialTheme());
const applyTheme = (theme: Theme) => {
if (browser) {
document.documentElement.setAttribute('data-theme', theme);
document.body.classList.toggle('theme-dark', theme === 'dark');
}
};
const toggleTheme = () => {
update((currentTheme) => {
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
return newTheme;
});
};
const setTheme = (theme: Theme) => {
localStorage.setItem('theme', theme);
applyTheme(theme);
set(theme);
};
// ... system preference handling coming next
return {
subscribe,
toggleTheme,
setTheme
};
};
Let’s break this down piece by piece:
The Store Creation
const { subscribe, set, update } = writable<Theme>(getInitialTheme());
This is like installing the main circuit breaker in our house. It controls the flow of state through our application.
The Theme Applier
const applyTheme = (theme: Theme) => {
if (browser) {
document.documentElement.setAttribute('data-theme', theme);
document.body.classList.toggle('theme-dark', theme === 'dark');
}
};
This function is our painter. It doesn’t just flip a switch; it repaints the entire house. We use both data-theme
and classes because:
data-theme
gives us a hook for CSS variables- The class toggle helps with third-party integrations
- Having both provides more styling flexibility
The Theme Toggler
const toggleTheme = () => {
update((currentTheme) => {
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
return newTheme;
});
};
This is our light switch. But unlike a simple switch, it:
- Checks the current state
- Determines the opposite
- Saves the preference
- Applies the change
- Updates the store
All of this happens in a specific order to prevent any “flash of wrong theme” – a common issue in less carefully crafted implementations.
Step 4: System Preference Synchronization
if (browser) {
const initialTheme = getInitialTheme();
applyTheme(initialTheme);
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
const savedTheme = localStorage.getItem('theme');
if (!savedTheme) {
const newTheme = event.matches ? 'dark' : 'light';
applyTheme(newTheme);
set(newTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
// Cleanup listener on store destruction
subscribe(() => {
return () => mediaQuery.removeEventListener('change', handleChange);
});
}
Step 5: The UI Component
Now, let’s look at the complete ThemeToggle.svelte
:
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte';
let { size = 20 } = $props();
const randId = Math.random().toString(36).slice(2); // Unique ID for SVG masks
const isDarkMode = $derived($theme === 'dark');
const rays = [
{ x1: 12, y1: 1, x2: 12, y2: 3 },
{ x1: 18.36, y1: 5.64, x2: 19.78, y2: 4.22 },
{ x1: 21, y1: 12, x2: 23, y2: 12 },
{ x1: 18.36, y1: 18.36, x2: 19.78, y2: 19.78 },
{ x1: 12, y1: 21, x2: 12, y2: 23 },
{ x1: 4.22, y1: 19.78, x2: 5.64, y2: 18.36 },
{ x1: 1, y1: 12, x2: 3, y2: 12 },
{ x1: 4.22, y1: 4.22, x2: 5.64, y2: 5.64 }
];
</script>
<button
on:click={() => theme.toggleTheme()}
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
class="theme-toggle theme-{isDarkMode ? 'dark' : 'light'}"
>
<svg class="sun-and-moon" aria-hidden="true" width={size} height={size} viewBox="0 0 24 24">
<mask class="moon" id="moon-mask-{randId}">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask-{randId})" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
{#each rays as { x1, y1, x2, y2 }, i}
<line {x1} {y1} {x2} {y2} style="transition-delay: {50 + i * 50}ms" />
{/each}
</g>
</svg>
</button>
<style>
button {
appearance: none;
background: transparent;
border: none;
padding: 0;
inline-size: fit-content;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
display: flex;
color: inherit;
transition: transform 0.3s var(--ease-elastic-5);
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.85);
}
}
svg {
outline-offset: 5px;
inline-size: 100%;
block-size: 100%;
stroke-linecap: round;
@media (hover: none) {
--size: 48px;
}
}
.sun-and-moon {
transform: rotate(90deg);
transition: transform 0.7s var(--ease-out-5);
:is(.moon, .sun, .sun-beams) {
transform-origin: center center;
}
:is(.moon, .sun) {
fill: var(--button-icon-fill, currentColor);
}
.sun-beams {
stroke: var(--button-icon-fill, currentColor);
stroke-width: 2px;
line {
transform-origin: 50% 50%;
}
}
.theme-dark & {
transform: rotate(-120deg);
.sun {
transform: scale(1.75);
transition-timing-function: var(--ease-3);
transition-duration: 0.25s;
}
.sun-beams line {
opacity: 0;
transform: scale(0);
transition-duration: 0.15s;
transition-delay: 0s !important;
}
.moon circle {
transform: translateX(-7px);
transition-delay: 0.25s;
transition-duration: 0.75s;
transition-timing-function: var(--ease-out-5);
@supports (cx: 1) {
transform: translateX(0);
cx: 17;
}
}
}
@media (prefers-reduced-motion: no-preference) {
.sun {
transition: transform 0.25s var(--ease-elastic-3);
}
.sun-beams {
transition:
transform 0.5s var(--ease-elastic-4),
opacity 0.5s var(--ease-3);
line {
transition:
opacity 0.15s var(--ease-3),
transform 0.25s var(--ease-elastic-2);
}
}
.moon circle {
@supports (cx: 1) {
transition: cx 0.25s var(--ease-out-5);
}
}
}
}
</style>
Let’s break down what makes this UI implementation special:
The Animation Magic
Instead of a simple toggle, we’re creating an animated transition between sun and moon icons. This is achieved through several clever techniques:
- SVG Masking:
<mask class="moon" id="moon-mask-{randId}">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
This creates the illusion of the moon “eating” the sun during transition. The random ID ensures multiple instances don’t conflict.
- Staggered Ray Animation:
style = 'transition-delay: {50 + i * 50}ms';
Each sun ray animates with a slight delay, creating a more organic feel. It’s like watching a flower bloom or close, one petal at a time.
Performance Considerations
- Transform Instead of Position:
transform: scale(1.1);
We use transform
for animations because it:
- Triggers GPU acceleration
- Doesn’t cause layout recalculations
- Provides smoother animations
- Reduced Motion Support:
@media (prefers-reduced-motion: no-preference) {
/* animations here */
}
We respect user preferences for reduced motion, making our component more accessible.
- Touch Optimization:
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
These properties optimize the button for touch devices, preventing unwanted behaviors.
Real-World Usage Examples: Putting Our Theme Switcher to Work
Listen: having a theme switcher is one thing, but making it play nicely with your actual application is another story entirely. Let’s explore some practical scenarios.
1. Setting Up Global CSS Variables
/* app.css or your global stylesheet */
:root {
/* Light theme defaults */
--background: hsl(0 0% 100%);
--text-primary: hsl(210 10% 15%);
--box-shadow: 0 2px 8px hsla(0 0% 0% / 0.1);
--accent-color: hsl(245 100% 60%);
}
[data-theme='dark'] {
--background: hsl(210 10% 10%);
--text-primary: hsl(0 0% 90%);
--box-shadow: 0 2px 8px hsla(0 0% 0% / 0.4);
--accent-color: hsl(245 100% 70%);
}
2. Using with SvelteKit Layouts
<!-- +layout.svelte -->
<script>
import { theme } from '$lib/stores/theme.svelte';
import { ThemeToggle } from '$lib/components';
</script>
<div class="app" data-theme={$theme}>
<header>
<nav>
<h1>My Awesome App</h1>
<ThemeToggle />
</nav>
</header>
<main>
<slot />
</main>
</div>
<style>
.app {
background: var(--background);
color: var(--text-primary);
min-height: 100vh;
transition: background-color 0.3s ease;
}
</style>
3. Creating Theme-Aware Components
<!-- Card.svelte -->
<script>
import { theme } from '$lib/stores/theme.svelte';
</script>
<div class="card">
<slot />
</div>
<style>
.card {
background: var(--background);
box-shadow: var(--box-shadow);
border-radius: 8px;
padding: 1.5rem;
transition: all 0.3s ease;
}
/* Optional: Different hover states for light/dark */
:global([data-theme='light']) .card:hover {
box-shadow: 0 4px 12px hsla(0 0% 0% / 0.15);
}
:global([data-theme='dark']) .card:hover {
box-shadow: 0 4px 12px hsla(0 0% 0% / 0.5);
}
</style>
4. Theme-Based Image Loading
<script>
import { theme } from '$lib/stores/theme.svelte';
const logoSrc = $derived($theme === 'dark' ? '/images/logo-light.svg' : '/images/logo-dark.svg');
</script>
<img src={logoSrc} alt="Logo" />
5. Third-Party Integration Example
// Integrating with a chart library like Chart.js
import { theme } from '$lib/stores/theme.svelte';
const chart = new Chart(ctx, {
// ... other config
options: {
theme: get(theme) === 'dark' ? 'dark' : 'light'
}
});
// Update chart when theme changes
theme.subscribe((newTheme) => {
chart.options.theme = newTheme === 'dark' ? 'dark' : 'light';
chart.update();
});
6. Conditional Styling with Theme
<script>
import { theme } from '$lib/stores/theme.svelte';
const getHighlightColor = $derived(
$theme === 'dark' ? 'hsla(45 100% 50% / 0.2)' : 'hsla(45 100% 50% / 0.1)'
);
</script>
<div class="highlight" style:background={getHighlightColor}>
<slot />
</div>
7. Theme-Aware Code Blocks
<!-- CodeBlock.svelte -->
<script>
import { theme } from '$lib/stores/theme.svelte';
import Prism from 'prismjs';
const highlighting = $derived(
Prism.highlight(code, Prism.languages.javascript, $theme === 'dark' ? 'okaidia' : 'default')
);
</script>
<pre class="language-javascript theme-{$theme}">
{@html highlighting}
</pre>
Remember: the key to a good theme implementation is consistency. Your theme should feel natural across all components, whether they’re built-in or third-party.
And here’s a pro tip: always test your theme implementation with real content. The prettiest theme switcher in the world won’t help if your users can’t read your actual content.
So it goes that a theme switcher is only as good as the theme system it’s switching between. But with these examples, you’re well-equipped to create a cohesive dark/light experience across your entire application.
And so it goes.