Persistent Theme Switch (Dark mode) with Svelte (SvelteKit) & Tailwind
Tags: svelte, tailwindcss, sveltekit, tutorial
Published: 2022-08-30 21:41:10

_Quick note - This tutorial was made with the following dependency versions: `"@sveltejs/kit": "next"` (1.1.3) & `"tailwindcss": "^3.2.4"`. If something doesn’t work as it should, consider upgrading. Alternatively, if your versions are ahead, check which breaking changes have been introduced since._

Source code can be found [here](https://github.com/WillKre/svelte-theme-switch).

<hr />

**Preconfiguration**

We'll be using SvelteKit for this tutorial. If you don't have a project set up yet, you can get started [here](https://kit.svelte.dev/docs/introduction#getting-started) (I recommend Skeleton + TypeScript).

If you haven't configured Tailwind and added it to an `app.css` file, there are easy-to-follow instructions on the [docs](https://tailwindcss.com/docs/guides/sveltekit).

<hr />

**Styling and using the Switch**

Now that everything's set up. We can start by creating the switch...

_`src/lib/ThemeSwitch/ThemeSwitch.svelte`_

```html
<div>
	<input type="checkbox" id="theme-toggle" />
	<label for="theme-toggle" />
</div>

<style lang="postcss">
	#theme-toggle {
		@apply invisible;
	}

	#theme-toggle + label {
		@apply inline-block cursor-pointer h-12 w-12 absolute top-6 right-24 rounded-full duration-300 content-[''];
	}

	#theme-toggle:not(:checked) + label {
		@apply bg-amber-400;
	}

	#theme-toggle:checked + label {
		@apply bg-transparent;
		box-shadow: inset -18px -16px 1px 1px #ddd;
	}
</style>

```

...and using it:

_`src/routes/+page.svelte`_ (or wherever else you may want to consume it, eg. `+layout.svelte`)

```html
<script lang="ts">
	import ThemeSwitch from '$lib/ThemeSwitch/ThemeSwitch.svelte';

	import '../app.css';
</script>

<ThemeSwitch />
<h1>Demo</h1>
```

(Note: If you get an error about referencing `$lib` like I did and are using Visual Studio Code, try [restarting your editor](https://github.com/sveltejs/language-tools/issues/1459#issuecomment-1149972032).)

At this point, you should have this:

![Toggling the switch](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4p86odkl74tm0ted1qr1.gif)

**Overriding Tailwind's dark mode**

To make dark mode integration as easy as possible, Tailwind includes a dark variant that lets you style your site differently when dark mode is enabled, eg: `class="bg-white dark:bg-slate-800"`.

That's cool, but we want to override the system default and toggle the dark mode manually. This means we'll need to make the following change:

_`tailwind.config.cjs`_

```ts
module.exports = {
  darkMode: 'class',
  // ...
}
```

Now, `dark:{class}` classes will be applied whenever the dark class is present earlier in the HTML tree, rather than being based on `prefers-color-scheme`.

You can verify this works by adding `class="dark"` to the opening `html` tag in `src/app.html`, then adding the following:

_`src/app.css`_ (You can also use a `global` style tag)

```css
body {
  @apply bg-white dark:bg-black text-black dark:text-white text-center;
}
```

This should give us this (Still a non-functional switch, but the dark mode is enabled):

![Toggling the switch, Dark mode enabled](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/df5t6n9epdmqfwcdjmkv.gif)

⚠️ Remember to remove the `class="dark"` from the opening `html` tag ⚠️

**Adding functionality to the Switch**

Time to add some functionality to that currently-useless switch. We want to replicate what we did by adding/removing the `dark` class from the root of the document.

We'll need to take control of the `checked` value of the `input` and action something `on:click`:

```html
<script lang="ts">
	let darkMode = true;

	function handleSwitchDarkMode() {
		darkMode = !darkMode;

		darkMode
			? document.documentElement.classList.add('dark')
			: document.documentElement.classList.remove('dark');
	}
</script>

<div>
	<input checked={darkMode} on:click={handleSwitchDarkMode} type="checkbox" id="theme-toggle" />
	<label for="theme-toggle" />
</div>
<!-- Styles... -->
```

Which gives:


![Switching between dark and light mode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lg819pfiazfri0132lp0.gif)

⚠️ Note - If your device is not set to dark mode like mine, your default behaviour will be different to mine - this will be dealt with soon ⚠️


**Setting the default values**

Utilising the [Window.mediaMatch()](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) API, we are able to update the `darkMode` value on load, dependant on what `prefers-color-scheme` the device is set to:

```html
<script lang="ts">
	import { browser } from '$app/env';

	let darkMode = true;

	function handleSwitchDarkMode() {
		darkMode = !darkMode;

		darkMode
			? document.documentElement.classList.add('dark')
			: document.documentElement.classList.remove('dark');
	}

	if (browser) {
		if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
			document.documentElement.classList.add('dark');
			darkMode = true;
		} else {
			document.documentElement.classList.remove('dark');
			darkMode = false;
		}
	}
</script>

<!-- html block... -->

<!-- style block... -->
```

You may have noticed the `if (browser)...`, that's just because we want to make sure that the code is only ran on the client (not server), otherwise the application would error with `500 - window is not defined`.

So to recap so far:
- We have added a switch which adds and removes `dark` as a class from root of the document.
- Given that we've updated the `tailwind.config.cjs` to go by class, styles are being picked up in the `body` block within `app.css` and being applied to the `background-color` and text `color`.
- We are detecting if the user has requested dark or light mode by default with [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) (which is a CSS [media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries#media_features)), and we are updating the `darkMode` state (which is required by the switch) and modifying the `classList` accordingly.

There's one more thing we need to do...

**Enabling persistence with Local Storage**

When we toggle the switch, we want to set the value in local storage:

`localStorage.setItem('theme', darkMode ? 'dark' : 'light');`

That will then allow us to check if the `theme` key exists, and if it does and matches the string `dark`, we know it's a positive match and can add the class and update the variable. If not, we do the opposite:

```ts
<script lang="ts">
	import { browser } from '$app/environment';

	let darkMode = true;

	function handleSwitchDarkMode() {
		darkMode = !darkMode;

		localStorage.setItem('theme', darkMode ? 'dark' : 'light');

		darkMode
			? document.documentElement.classList.add('dark')
			: document.documentElement.classList.remove('dark');
	}

	if (browser) {
		if (
			localStorage.theme === 'dark' ||
			(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
		) {
			document.documentElement.classList.add('dark');
			darkMode = true;
		} else {
			document.documentElement.classList.remove('dark');
			darkMode = false;
		}
	}
</script>
```

Let's add a finishing touch to the `body` so that we can get a less abrupt transition between the 2 themes: 

_`app.css`_

```css
body {
	transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
	@apply bg-white dark:bg-black text-black dark:text-white text-center;
}
```

Now let's see it in action:


![Switch with local storage persistence](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3n7ky2qhhd9vfyrrhi8r.gif)

You can also do the initialisation in a `<svelte:head>` to avoid FOUC (Flash of Unstyled Content).

That's it! The theme (`'dark' | 'light'`) is now persisting on refresh! 🪄

Source code can be found [here](https://github.com/WillKre/svelte-theme-switch).

If you need any further information on the above or anything else (eg. adding new environments such as `staging`) check out the [Vite docs](https://vitejs.dev/guide/), the [SvelteKit docs](https://kit.svelte.dev/docs/introduction) or the official [Svelte discord](svelte.div/chat).