Dark mode toggle without JavaScript enabled using SvelteKit

Note: for interactive bits, see my original blog post.

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.

15