Theme Switcher

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 with form.requestSubmit(). We're updating data-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! 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, use applyAction
  • 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 the ActionResult. Note that if you return a callback, the default behavior mentioned above is not triggered. To get it back, call update.

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: Update Options

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.