Dark mode toggle without JavaScript enabled using SvelteKit
Tags: javascript, tutorial, svelte, tailwindcss
Published: 2021-06-23 19:01:12

---
canonical_url: https://www.davidwparker.com/posts/dark-mode-in-sveltekit-with-and-without-javascript
---

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](https://youtu.be/RAY9Evez3oc).

## The Code Break Down

### stores

```javascript
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](https://stackoverflow.com/a/61300826/2933427).

### getSession hook

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

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

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

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

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

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

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

<ThemeToggle />

### toggleTheme

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

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

<SvgCard id="oh-my-a-cat" href={cat1} r="72px"/>