DEV Community

Cover image for Persistent Theme Switch (Dark mode) with Svelte (SvelteKit) & Tailwind
Will
Will

Posted on • Edited on

Persistent Theme Switch (Dark mode) with Svelte (SvelteKit) & Tailwind

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.


Preconfiguration

We'll be using SvelteKit for this tutorial. If you don't have a project set up yet, you can get started here (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.


Styling and using the Switch

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

src/lib/ThemeSwitch/ThemeSwitch.svelte



<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>



Enter fullscreen mode Exit fullscreen mode

...and using it:

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



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

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

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


Enter fullscreen mode Exit fullscreen mode

(Note: If you get an error about referencing $lib like I did and are using Visual Studio Code, try restarting your editor.)

At this point, you should have this:

Toggling the switch

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



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


Enter fullscreen mode Exit fullscreen mode

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)



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


Enter fullscreen mode Exit fullscreen mode

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

Toggling the switch, Dark mode enabled

⚠️ 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:



<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... -->


Enter fullscreen mode Exit fullscreen mode

Which gives:

Switching between dark and light mode

⚠️ 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() API, we are able to update the darkMode value on load, dependant on what prefers-color-scheme the device is set to:



<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... -->


Enter fullscreen mode Exit fullscreen mode

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 (which is a CSS media feature), 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:



<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>


Enter fullscreen mode Exit fullscreen mode

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

app.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;
}


Enter fullscreen mode Exit fullscreen mode

Now let's see it in action:

Switch with local storage persistence

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.

If you need any further information on the above or anything else (eg. adding new environments such as staging) check out the Vite docs, the SvelteKit docs or the official Svelte discord.

Top comments (6)

Collapse
 
vmribeiro profile image
Victor Ribeiro

Dude I created an account just to thank you, I spent 1 entire day trying to fix the persistence in svelte and your post worked at first

Collapse
 
mapsgeek profile image
ibrahim mohammed

Thanks! works greate

Collapse
 
vitroidfpv profile image
Vít Semrád • Edited

Hi, thanks for the tutorial, it's really useful!

Could you please elaborate on

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

It seems like an off-hand comment, but I think it's pretty important to avoid any flashes, how would you go about doing it in <svelte:head>?

Collapse
 
willkre profile image
Will • Edited

Hey @vitroidfpv thanks! You may notice that dependent on what your device theme is set to and what you have set in local storage, when you load the page it "flashes" to the other color right after the initial load (i.e. you can see the background transition from white to black or vice versa).

You can actually see it on the last GIF in the guide when I refresh a second time on the black background. The page refreshes showing an initial white background, then the <ThemeSwitch /> renders a split second later and applies the dark mode styles.

There are a few ways to avoid this, one easy way is to just check & apply the theme in the <script> tag within app.html so that it's applied before the content loads.

I've just updated my template to include it, feel free to check it out here

Collapse
 
hbartolin profile image
Hrvoje Bartolin

Just wondering, when you in dark mode avery page gets flickering. It is solution with adapter-static?

Collapse
 
willkre profile image
Will

Hey, the flicker can be fixed by applying what is said in this comment: dev.to/willkre/comment/2483i