DEV Community

Cover image for Adding Dark Mode to an ElderJS Site
mattstobbs
mattstobbs

Posted on • Edited on • Originally published at mattstobbs.com

Adding Dark Mode to an ElderJS Site

This post was originally posted on my blog, Adding Dark Mode to an ElderJS Site. Some changes have been made from the original post to fit the styling of dev.to. I recommend reading the post on the original site to see it styled as intended.

One of the trickiest parts of creating this site was implementing the dark mode. I thought it would be simple:

  1. Use CSS variables for all the colours. CSS variables are reactive, so they'll automatically update the colours on the page if their values change.
  2. Define two sets of CSS variables, a default value for light mode and a dark mode value for when the body node has a class of dark.
  3. Toggle the body node's dark class to switch between light and dark mode.
body {
  --colour-background: #ffffff;
  /* Define the other light-mode colours here */

  background: var(--colour-background);
}

body.dark {
  --colour-background: #111827;
  /* Define the other dark-mode colours here */
}
Enter fullscreen mode Exit fullscreen mode

However, with this approach, we don't remember what the user's preference is. ElderJS doesn't use client-side routing, so when you navigate the site, each page will fall back to the default light mode. Refreshing the page or returning to the site later gives us the same problem.

It turns out solving this problem is more complicated than it seems. In this post, we'll look at how I implemented dark-mode for this blog so that the user's choice of theme is always the one they see.

A huge inspiration for this post is taken from Josh W. Comeau's excellent blog post The Quest for the Perfect Dark Mode.

While that post was written for sites built with Gatsby.js, the main strategy behind it was used for this post. It's worth reading if you're interested in learning more about why this approach was chosen.

If you just want to jump to where we start coding our final solution, you can find that here.

Proposed Initial Solution

When the user toggles between light and dark mode, we'll store their choice in localStorage.

When a user navigates to our page, we'll see if they have a previous value saved and use it as the initial value.

If localStorage doesn't define a value, we'll use their operating system preferences as our default. If no theme preferences are available, we'll use light mode as our default.

A flow chart showing the above requirements.

Our code will look something like this:

function getInitialColourMode() {
  const persistedColourPreference = window.localStorage.getItem('colour-mode');
  const hasPersistedPreference = typeof persistedColourPreference === 'string';

  if (hasPersistedPreference) {
    return persistedColourPreference;
  }

  const mql = window.matchMedia('(prefers-color-scheme: dark)');
  const hasMediaQueryPreference = typeof mql.matches === 'boolean';

  if (hasMediaQueryPreference) {
    return mql.matches ? 'dark' : 'light';
  }

  return 'light';
}
Enter fullscreen mode Exit fullscreen mode

One Last Hurdle

Running this code in one of our components, such as the onMount of our layout component, exposes the last hurdle we need to overcome - the dreaded light-flash 😱

The problem is that we only have access to the window after the components have mounted. Therefore, the page renders using the default value of "light-mode" before our code runs and switches the page to dark mode.

We need a way of running our JavaScript before the page renders, which means we need to run it outside the Svelte components. We can do this by inserting a script tag before the <body> element of our HTML. Script tags are blocking, so placing it before the <body> element will mean the JavaScript inside will run before the page renders.

Implementing Dark-Mode

Ok, we're finally ready to start coding!

Setting the correct initial theme

Let's start with inserting the script tag before the <body> element to get our initial dark-mode value. One of the most powerful features of ElderJS is hooks, which allow us to plug into and customise any part of the page generation process.

We want to add the script to the head, so we'll use the stacks hook. It exposes a prop called headStack which we can mutate to add elements to the head:

// src/hooks.js

const hooks = [
  {
    hook: 'stacks',
    name: 'addDarkModeScript',
    description: 'Adds script to check for existing dark mode preferences',
    priority: 5,
    run: async ({ headStack }) => {
      const codeToRunOnClient = `
      <script>
        (function() {
          function getInitialColourMode() {
            // same as above - removed for brevity
          }

          const colourMode = getInitialColourMode();
          if (colourMode === 'dark') {
            document.documentElement.classList.add('dark');
          }
        })()
      </script>`;

      headStack.push({
        source: 'addDarkModeScript',
        string: codeToRunOnClient,
        priority: 80,
      });
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

We use getInitialColourMode to find our initial colour mode from the user's pre-defined preferences. If it's 'light', we don't need to do anything - that's our default. If it's 'dark', we'll add a 'dark' class to our HTML root element (this is running before the <body> element, so, for our purposes, the root element will be the only defined element).

Why do we define a new function and immediately call it?

This is called an IIFE (Immediately Invoked Function Expression). The main idea is that we won't be polluting the global namespace because everything is scoped within a function.

Showing the correct colours

Now that the root element has the right class, we can use CSS variables to show the correct colours. This is the same as the code in the introduction, but now our .dark class is on the HTML element.

body {
  --colour-background: #ffffff;
  /* Define the other light-mode colours here */

  background: var(--colour-background);
}

html.dark body {
  --colour-background: #111827;
  /* Define the other dark-mode colours here */
}
Enter fullscreen mode Exit fullscreen mode

Now we're showing the correct initial value without any incorrect flashes after the page loads 🎉

Toggling the Theme

The last step is to allow the user to toggle the theme. We need a button/toggle which toggles the root element's class when clicked, and stores that new value to localStorage.

The only complication is that we won't know what the initial value should be when the component mounts. To solve this, we'll use Josh W. Comeau's solution: defer rendering the toggle until after we can read the initial value.

There are lots of ways to display a toggle. If you use a switch component, I recommend basing it off a library like Headless UI to ensure the component is fully accessible. For my blog, I use <Moon /> and <Sun /> components, which are just SVGs from Feather Icons.

<script>
  import { onMount } from 'svelte';
  import Moon from './icons/Moon.svelte';
  import Sun from './icons/Sun.svelte';

  const darkModeClass = 'dark';

  let isDarkMode;
  onMount(() => {
    isDarkMode = window.document.documentElement.classList.contains(darkModeClass);
  });

  const toggle = () => {
    window.document.documentElement.classList.toggle(darkModeClass);
    isDarkMode = window.document.documentElement.classList.contains(darkModeClass);
    window.localStorage.setItem('colour-mode', isDarkMode ? 'dark' : 'light');
  };
</script>

{#if typeof isDarkMode === 'boolean'}
  <button aria-label="Activate dark mode" title="Activate dark mode" on:click={toggle}>
    {#if isDarkMode}
      <Moon />
    {:else}
      <Sun />
    {/if}
  </button>
{/if}
Enter fullscreen mode Exit fullscreen mode

Success 🎉

We've successfully created a dark-mode toggle for our ElderJS site, which shows the user's preferred theme when they first see the page in all its glory. First impressions matter, so it's vital to get the details right in the first few seconds of a user's experience.

If there is enough interest, this would be an excellent candidate for an ElderJS plugin. In the meantime, if you have any questions, feel free to reach out to me on Twitter.

Top comments (0)