Dark Mode in SvelteKit with and without JavaScript
Dark mode is cool. Or, at a minimum, it’s expected to be there nowadays. A lot of sites have dark mode, but not every site takes the time to make a good user experience for users without JavaScript enabled. In this post, I show how you can use SvelteKit endpoints, hooks, cookies, and load in order to set dark mode with and without JavaScript enabled in order to give your users the best User Experience that you can.
Note: if you’d rather watch a video tutorial, you can check out my YouTube video here.
The Code Break Down
stores
export const theme = createWritableStore('theme', { mode: 'dark', color: 'blue' });
First, we’ll create a localStorage-based store that keeps our theme mode
in it. You can ignore color
for now, we’ll be adding that another time. createWritableStore
was taken from this stackoverflow post.
getSession hook
import cookie from 'cookie';
export const getSession = async (request) => {
const cookies = cookie.parse(request.headers.cookie || '');
const theme = cookies.theme || 'dark';
return {
theme,
};
};
For the getSession
hook, we just want to get the value of our theme from a cookie, and otherwise default it to dark
mode. This will be accessible in load
in our components later.
handle hook
export const handle = async ({ request, render }) => {
// TODO https://github.com/sveltejs/kit/issues/1046
const response = await render({
...request,
method: (request.query.get('_method') || request.method).toUpperCase(),
});
const cookies = cookie.parse(request.headers.cookie || '');
let headers = response.headers;
const cookiesArray = [];
if (!cookies.theme) {
const theme = request.query.get('theme') || 'dark';
cookiesArray.push(`theme=${theme};path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT`);
}
if (cookiesArray.length > 0) {
headers = {
...response.headers,
'set-cookie': cookiesArray,
};
}
return {
...response,
headers,
};
};
In handle
, you can skip the beginning (copied from the demo app), and starting at the line const cookies =
, we check to see if we don’t have a theme cookie yet. If we don’t then we go ahead and set it to a query param of theme if provided, or default to dark
mode. We then set the cookiesArray to our set-cookie
header for SvelteKit. This allows us to set a cookie for the first request. Sadly, we don’t have access to the user’s prefers-color-scheme
here, so we can’t default to their preference yet. We’ll do it later in the frontend for users with JS enabled.
__layout.svelte > load
<script context="module">
export async function load({ session }) {
const localTheme = session.theme;
return { props: { localTheme } };
}
</script>
Within our module
context and load
function, we get our theme from the session. This will be used below to set on a div to ensure everything looks correct without JS enabled.
__layout.svelte > script + onMount
<script>
import { onMount } from 'svelte';
import Nav from '$lib/app/navbar/Nav.svelte';
import { theme } from '$lib/shared/stores';
export let localTheme;
// We load the in the <script> tag in load, but then also here onMount to setup stores
onMount(() => {
if (!('theme' in localStorage)) {
theme.useLocalStorage();
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
localTheme = 'dark';
theme.set({ ...$theme, mode: 'dark' });
} else {
localTheme = 'light';
theme.set({ ...$theme, mode: 'light' });
}
} else {
theme.useLocalStorage();
}
document.documentElement.classList.remove('dark');
});
</script>
__layout.svelte > svelte:head
<svelte:head>
<script>
if (!('theme' in localStorage)) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
document.cookie = 'theme=dark;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;';
} else {
document.documentElement.classList.remove('dark');
document.cookie = 'theme=light;path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT;';
}
} else {
let data = localStorage.getItem('theme');
if (data) {
data = JSON.parse(data);
document.documentElement.classList.add(data.mode);
}
}
</script>
</svelte:head>
These two mostly do the same thing, but the latter one (svelte:head) will be used to set or remove dark
if we haven’t had anything set in localStorage. So for users with JS enabled, we can get their preferred setting and override the dark
cookie which we set in getSession
- just an added nicety for users with JS on. The latter also blocks so will show up without a flicker. The onMount
will run later and keep our localStorage store in sync with the rest.
__layout.svelte > html
<div id="core" class="{localTheme}">
<main class="dark:bg-black bg-white">
<Nav />
<slot />
</main>
</div>
This last bit shows how we set the localTheme
class, which is sent from load
as a prop
. It’s created from the cookie value which is provided in the getSession
hook.
Nav.svelte
<script>
import { theme } from '$lib/shared/stores';
import { toggleTheme } from '$lib/shared/theme';
import { UiMoonSolid, UiSunOutline } from '$lib/components/icons';
const klass = 'px-3 py-2 rounded-md leading-5 font-medium focus:outline-none focus:text-white focus:bg-primary-300 text-neutral-800 hover:text-white hover:bg-primary-300 dark:text-white dark:hover:bg-primary-700 dark:focus:bg-primary-700 dark:bg-black';
</script>
<nav>
<a
href="/app/theme"
class="block {klass}"
aria-label="Toggle Light and Dark mode"
on:click|preventDefault={() => {
toggleTheme(theme, $theme);
}}
>
<div class="hidden dark:block">
<UiSunOutline />
</div>
<div class="dark:hidden">
<UiMoonSolid />
</div>
</a>
</nav>
The nav itself is pretty simple. We have a single link, which will create a GET
request. For users with JS enabled, we call toggleTheme
. For those without JS enabled, it will fall back to the /app/theme
endpoint. It uses Tailwind dark:block
and dark:hidden
to show/hide the correct icon.
toggleTheme
export function toggleTheme(theme: any, $theme: any): void {
if ($theme.mode === 'light') {
theme.set({ ...$theme, mode: 'dark' });
updateDocument('theme', 'dark', 'light');
} else {
theme.set({ ...$theme, mode: 'light' });
updateDocument('theme', 'light', 'dark');
}
}
function updateDocument(name: string, klass: string, other: string) {
document.cookie = `${name}=${klass};path=/;expires=Fri, 31 Dec 9999 23:59:59 GMT`;
document.getElementById('core').classList.remove(other);
document.documentElement.classList.remove(other);
document.getElementById('core').classList.add(klass);
document.documentElement.classList.add(klass);
}
These two convenience methods will be used to set the Svelte store, set a cookie, and update the DOM with our preferred light
or dark
mode.
/app/theme endpoint
import cookie from 'cookie';
import type { RequestHandler } from '@sveltejs/kit';
// GET /app/theme
export const get: RequestHandler = async (request) => {
const cookies = cookie.parse(request.headers.cookie || '');
let theme = cookies.theme;
theme = theme === 'dark' ? 'light' : 'dark';
return {
status: 303,
headers: {
location: '/',
'set-cookie': `theme=${theme}; path=/; expires=Fri, 31 Dec 9999 23:59:59 GMT`,
},
body: '',
};
};
For users without JS enabled, the link will hit this GET
endpoint. Like getSession
and handle
we parse the cookies to get the theme. If it’s currently set to dark
we change it to light
, and vice versa. We then return an object for SvelteKit to know to 303, redirecting to /
and setting the cookie for the new value we need, along with an empty body. Note that GET
requests should normally be idempotent, so if you want to move this to a POST
, PUT
or a PATCH
that would work too.
Summary
All in all, it wasn’t too hard to implement a theme toggle for dark mode in SvelteKit which works with both JS enabled and disabled. With SvelteKit, this becomes extremely easy, and you can provide all of your users with a premium user experience.