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:
- 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.
- 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
. - 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 */
}
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.
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';
}
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,
});
},
},
];
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 */
}
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}
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)