Here is how I implemented the theme switcher on this site. Most (well, all) of the hard work was done by the folks at skeleton.dev, I just had to hunt down the information and make it in work in my project. If you aren't interested in the process, and just want to see the final product, check out the source code on GitHub.
Skeleton Themes
Skeleton is
An adaptive design system for extending Tailwind.
One of the cool things about Skeleton, and why I chose to use it for this site, is the powerful Theme system.
Themes are the heart of the Skeleton’s design system, empowered by CSS custom properties, authored as CSS-in-JS to enable simple integration into Skeleton’s Tailwind plugin. Which allows for simple pre-registration and switching on demand.
Getting It To Work (Poorly)
In order to activate a theme, you need to set the data-theme
property on the <body>
element in your HTML.
You would think then, that it would be easy enough to switch between themes. Just create a dropdown with the list of themes, bind the selection to a $state variable, then on dropdown selection use Javascript's DOM methods to update the property, e.g.
<!-- src/lib/components/ThemeSwitcher.svelte -->
<script>
let theme = $state({theme: ''});
const themes = [
'catppuccin',
'cerberus',
...etc,
'vox',
'wintry'
];
function updateTheme() {
document.body.dataset.theme = theme.theme;
}
</script>
<form>
<select bind:value={theme.theme} onchange={() => updateTheme()}>
{#each themes as themeOption}
<option>{themeOption}</option>
{/each}
</select>
</form>
This works. Kinda.
The big issue is that the theme doesn't persist when the page is refreshed. If we want that to happen, we'll have to use some sort of persistent storage - like local storage or cookies.
Making It Better
Let's refactor our code to utilize cookies. We'll create a +page.server.ts
to export a form action to set the the theme cookie, and a +layout.server.ts
to load the cookie and provide it to the page.
// src/routes/+page.server.ts
export const actions = {
setTheme: async ({ cookies, request }) => {
const formData = await request.formData();
const theme = formData.get('theme')?.toString() ?? 'cerberus';
cookies.set('theme', theme, { path: '/' });
return { theme };
}
};
// src/routes/+layout.server.ts
export async function load({cookies}) {
const theme = cookies.get('theme') ?? 'cerberus'
return {
theme: theme
};
}
And let's update our component to use this new form action.
<!-- src/lib/components/ThemeSwitcher.svelte -->
<script>
let theme = $state({theme: ''});
const themes = [
'catppuccin',
'cerberus',
...etc,
'vox',
'wintry'
];
let form: HTMLFormElement;
const setTheme = ({ formData }) => {
const formTheme = formData.get('theme')?.toString();
if (formTheme) {
document.body.dataset.theme = formTheme;
document.body.setAttribute('data-theme', formTheme);
theme.theme = formTheme;
}
};
</script>
<form bind:this={form} action="/?/setTheme" method="POST" use:enhance={setTheme}>
<select name="theme" bind:value={theme.theme} onchange={() => form.requestSubmit()}>
{#each themes as themeOption}
<option>{themeOption}</option>
{/each}
</select>
</form>
Some changes we made:
- Created the
setTheme
function for form enhancement to make the change client-side so we don't have to wait for the server action before we see the effects. - Replaced our
updateTheme()
onchange
handler withform.requestSubmit()
. We're updatingdata-theme
in the form enhancement now, so we just need a way to submit the form, so we'll trigger the form submission on selection. - Added the action to the form.
Finally, let's update our +layout.svelte
to update the <body>
data-theme
attribute on mount.
// src/routes/+layout.svelte
<script>
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
let { data, children } = $props();
onMount(() => {
document.body.dataset.theme = data.theme;
});
</script>
<ThemeSwitcher />
Now let's see how it works.
Better! But still not great. The theme is persisting through reloads, but there is a "flash" - for a brief moment the page uses the default theme while it loads the cookie to set the selected theme. And the dropdown selection is being reset.
Making It Perfect
Getting Rid Of The Flash
The problem we are running into is that Svelte serves the initial HTML from the server (where the <body>
data-theme
attribute is initially set as a default, cerberus
in this case). After that initial HTML has been served, we are depending on the client (browser) to update that attribute, after loading the cookie which is not an instant operation. In the short time between the initial server HTML being loaded and the client running the update, we see the page use the default theme.
What we really need to do, is have the server provide the HTML with correct data-theme
already set.
This is where the power of hooks comes in. The hooks!
Let's create a hooks.server.ts
and have it replace the data-theme
attribute with whatever is set in our cookie.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
let theme = '';
const cookieTheme = event.cookies.get('theme');
if (cookieTheme) {
theme = cookieTheme;
} else {
// Default to cerberus
event.cookies.set('theme', 'cerberus', { path: '/' });
theme = 'cerberus';
}
return await resolve(event, {
transformPageChunk: ({ html }) => html.replace('data-theme="cerberus"', `data-theme="${theme}"`)
});
};
We can also get rid of the onMount()
bit in +layout.svelte
, since we'll be serving the HTML with the correct theme already set.
The result:
Awesome! We have a persistent theme without any flashing! But there's one last thing bothering me - the dropdown selection. It would be nice if the selection always matched the current theme. Let's make that happen.
Fixing The Dropdown Selection
Let's update our component to accept a currentTheme
prop and set the <select>
value
property to it's value.
<!-- src/lib/components/ThemeSwitcher.svelte -->
<script>
let theme = $state({theme: ''});
const themes = [
'catppuccin',
'cerberus',
...etc,
'vox',
'wintry'
];
let { currentTheme } = $props();
let form: HTMLFormElement;
const setTheme = ({ formData }) => {
const formTheme = formData.get('theme')?.toString();
if (formTheme) {
document.body.dataset.theme = formTheme;
document.body.setAttribute('data-theme', formTheme);
theme.theme = formTheme;
}
};
</script>
<form bind:this={form} action="/?/setTheme" method="POST" use:enhance={setTheme}>
<select value={currentTheme} onchange={() => form.requestSubmit()}>
{#each themes as themeOption}
<option>{themeOption}</option>
{/each}
</select>
</form>
We'll update +layout.svelte
to provide the currentTheme
prop with the value of the theme cookie.
<!-- src/routes/+layout.svelte -->
<script>
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import '../app.css';
let { data, children } = $props();
</script>
<ThemeSwitcher currentTheme={data.theme} />
How's it looking?
Perfect! Well, almost. There is one last issue - did you catch it?
Jarvis? Enhance and playback at half speed, please.
Now the selection is flashing! Ahh!
Getting Rid Of The Flash: Part 2, Electric Boogaloo
What is happening? We set the <select>
value
to the currentTheme
, but upon selection the form is being reset, and for a brief moment we see that default selection flash on the screen.
The default behavior of a form is to reset all selections. Since we are using custom form enhancement, but we aren't returning a callback function, the default form behavior is being used. From the Svelte docs:
Without an argument,
use:enhance
will emulate the browser-native behaviour, just without the full-page reloads. It will:
- update the
form
property,$page.form
and$page.status
on a successful or invalid response, but only if the action is on the same page you’re submitting from. For example, if your form looks like<form action="/somewhere/else" ..>
,form
and$page
will not be updated. This is because in the native form submission case you would be redirected to the page the action is on. If you want to have them updated either way, useapplyAction
- reset the
<form>
element- invalidate all data using
invalidateAll
on a successful response- call
goto
on a redirect response- render the nearest
+error
boundary if an error occurs- reset focus to the appropriate element
To customise the behaviour, you can provide a
SubmitFunction
that runs immediately before the form is submitted, and (optionally) returns a callback that runs with theActionResult
. Note that if you return a callback, the default behavior mentioned above is not triggered. To get it back, callupdate
.
We do want most of that default behavior, but we don't want to reset the <form>
element. And there is an (undocumented) way to do this!
Let's return a callback in our form enhancement:
<!-- lib/components/ThemeSwitcher.svelte -->
<script>
// ...
const setTheme: SubmitFunction = ({ formData }) => {
const formTheme = formData.get('theme')?.toString();
if (formTheme) {
document.body.dataset.theme = formTheme;
document.body.setAttribute('data-theme', formTheme);
theme.theme = formTheme;
}
return async ({ update }) => {
update();
};
};
// ...
</script>
And let's hover over the update
function to see it's type hints:
Aha! That reset
option is exactly what we're looking for!!
Let's try it out!
<!-- lib/components/ThemeSwitcher.svelte -->
<script>
// ...
const setTheme: SubmitFunction = ({ formData }) => {
const formTheme = formData.get('theme')?.toString();
if (formTheme) {
document.body.dataset.theme = formTheme;
document.body.setAttribute('data-theme', formTheme);
theme.theme = formTheme;
}
return async ({ update }) => {
update({ reset: false });
};
};
// ...
</script>
We'll also change the <select>
to bind currentTheme
instead of just using it for the initial value.
<!-- lib/components/ThemeSwitcher.svelte -->
<!-- ... -->
<form bind:this={form} action="/?/setTheme" method="POST" use:enhance={setTheme}>
<select bind:value={currentTheme} onchange={() => form.requestSubmit()}>
{#each themes as themeOption}
<option>{themeOption}</option>
{/each}
</select>
</form>
The final result:
References
This was the process of trial and error I ran into when trying to implement the theme switcher. But I did not come to these conclusions on my own, and in fact, stole learned most of it from Skeleton's own implementation on their (Svelte 4) website.
How I really got this to work, was by joining the Skeleton Discord and searching until I found this post from Chris, the creator of the Skeleton project. With that, and looking at the Skeleton website source code, I was able to get most of the way there.
The last piece (the select form being reset) I discovered, after pulling my hair out for 40 minutes, was this StackOverflow post.
You can find the complete code for this demo on GitHub.