Three-State Light/Dark Theme Switch

In recent years more websites have implemented "dark" mode, a theme that reduced the light emitted by the device's screen and therefore causing the reader less eye strain. Research by the Harvard Medical School shows that extended exposure to blue light can affect how well we sleep during the night. The science behind this is more complex, and in this article, we are not exploring this subject.

What we'll discuss, however, is the correct implementation of the light/dark theme in modern websites. Large companies - most notable in the recent months, Stack Overflow and GitHub have also implemented a dark theme. After doing some research, which involved visiting numerous sites and reading articles around the subject, I found that there seem to be two distinctive implementation options for switching between themes.

Before I continue, I wanted to thank Chee Aun for pointing me in the right direction regarding the various state options for the light/dark theme.

Two-state theme switch

The first option is what we could call a "two-state" switch. In these implementations, we can switch between the light and the dark theme, as expected. Different CSS frameworks offer different solutions, including TailwindCSS. Let's take a look at a potential implementation of this using the framework mentioned above.

Enabling dark mode using Tailwind, there are two options: class and media. media considers the prefers-color-scheme feature; however if we'd like to enable switching between the themes manually, we need to use class.

Enabling the manual switch requires the darkMode property added to the tailwind.config.js file with a value of class.

// tailwind.config.js
module.exports = {
darkMode: 'class',
theme: {},
variants: {
extend: {},
},
plugins: [],
};

Once this configuration is done, dark variants can be added to our application, in the following way:

<button id="light">Light</button> | <button id="dark">Dark</button>
<p class="text-black dark:text-white">Hello there!</p>

In the above example, the text will be black for the light theme; however it will change to white if the dark theme is enabled.

The question is, how can we "enable" the dark theme? We can write some JavaScript to do the work for us:

const htmlClasses = document.querySelector('html').classList;
document.getElementById('dark').addEventListener('click', () => {
htmlClasses.add('dark');
localStorage.theme = 'dark';
document.getElementById('light').hidden = false;
document.getElementById('dark').hidden = true;
});

document.getElementById('light').addEventListener('click', () => {
htmlClasses.remove('dark');
localStorage.theme = 'light';
document.getElementById('light').hidden = true;
document.getElementById('dark').hidden = false;
});

The code above takes care of adding or removing the dark variant from all our HTML, furthermore, it also shows and hides the appropriate button. Astute readers can also notice that we are using localStorage. Saving the selected theme permanently means when visitors return to our site, we can grab the value from localStorage and apply that theme straight away. The code below demonstrates this.

if (
localStorage.theme === 'dark' ||
(localStorage.getItem('theme') === null &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.getElementById('dark').hidden = true;
document.querySelector('html').classList.add('dark');
} else if (localStorage.theme === 'dark') {
document.getElementById('dark').hidden = true;
document.querySelector('html').classList.add('dark');
} else {
document.getElementById('light').hidden = true;
}

Furthermore, we also check for the system preference via window.matchMedia("(prefers-color-scheme: dark)").matches which returns either dark or light and based on that we set the right theme.

A working example of this can be found below.

The code is also available for the above project.

Problems with a two-state switch

You are probably wondering, what is wrong with the solution above? And you're right, and it works as expected. We can switch between the light and dark themes. Truth be told, I also thought that this solution is great until I did some research.

The main issue with the two-state approach is that there's no way to go back to using the "system" setting. In other words, if we set up our operating system to use the dark theme, and then we change to any of the themes using the buttons inside the app, there's no way that we can tell the site to go back and use the OS-level setting. And this leads to a usability problem.

The only way I can "reset" my theme and make it use the OS setting is to clear the localStorage manually. You can't expect your visitors to do this. Deleting the theme entry from localStorage will trigger the following condition in our code, and the appropriate theme will be loaded:

// window.matchMedia('(prefers-color-scheme: dark)').matches will take the OS level setting
if (
localStorage.theme === 'dark' ||
(localStorage.getItem('theme') === null &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
// set dark theme
}

Three-state theme switch

StackOverflow and GitHub (and many others) utilise three-states for their dark/light theme. This means that you can change the state to be dark, ligth and system. This method solves the problem that we discussed and effectively allows visitors to "reset" and resort to the OS level setting, withing having to manually delete anything from localStorage.

There are several available implementation strategies for using three-states. If a registration/login system is in place, the theme selector can be placed under the profile settings. Sometimes you would also see a dropdown that allows the selection of three options. Any of these strategies are viable.

On this blog, you'll also find a three-state theme selector.

Let's take a look at an example implementation of how a three-state switch would work.

The code is also available for the above project.

In this example, we see that a cogwheel icon appeared. The icon is either going to be blue or yellow - indicating whether the underlying operating system is using a light (yellow) or a dark (blue) theme.

Do note that even though we talk about three-states, we need to work with four: light, dark, system light and system dark.

The logic behind the three-state switch is rather simple. If localStorage doesn't contain a theme property - in other words, users have not selected any of the themes, and the default OS one is picked up.

Users can still choose a theme, and until they click on the cogwheel again, their preference will be stored. Basically, clicking on the cogwheel icon will eliminate the theme property from localStorage.

But there's more! If you're like me, you have your OS setup to alternate between the light and dark mode based on some rule - in my case, that rule is the default 'sunset/sunrise' model, where during daytime, the light theme is used, and after sunset, the OS switches to the dark theme.

To pick up this change automatically, we can implement an event listener, like the one below:

window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
console.log('called dark');
if (e.matches && !localStorage.theme) {
// switch to a 'system dark' theme
}
});

window.matchMedia('(prefers-color-scheme: light)').addListener((e) => {
console.log('called dark');
if (e.matches && !localStorage.theme) {
// switch to a 'system light' theme
}
});

The above code makes sure that when there's a change in the user's theme setting, we immediately apply that change.

It's really easy to test this feature. Chrome DevTools have a feature that mimics the dark/light mode. To use this feature, open up DevTools, press SHIFT + CMD + P (or SHIFT + CTRL + P on Windows) and type in dark which should bring up an option Rendering: Emulate CSS prefers-color-scheme: dark. (Typing in light would bring up the light colour theme switch). Selecting this option will enforce either the dark or the light theme, and it's immensely useful for testing the system theme variant.

FART

I know, I know. That title is somewhat tongue-in-cheeck but bear with me. FART is a term coined by Chris Coyier and it refers to Flash of inAccurate coloR Theme. Essentially it's a visual artefact that happens when the system setting is different from the user preference - in other terms, if the user has selected the dark theme but uses a OS-level light theme, we may get a visual flicker, because the dark theme is enabled via JavaScript.

This problem is especially true for sites that have been pre-rendered/pre-generated or server-side rendered (think Jamstack). The video below demonstrates the problem - the page is refreshed a number of times while the light theme is selected at the OS level, but the dark theme has been selected by the user.

Avoiding the flicker

There are different strategies available for different frameworks, one example is Rob Morieson's solution using Next.js.

Some other, simpler solutions can be implemented. The one that we'll take a look at is related to 11ty - a popular, JavaScript-based static site generator.

The issue at hand, as we discussed, is that the page flashes if there's a difference between the OS and the user-selected theme. We can leverage JavaScript's blocking feature and play that to our advantage. We can check early on if the user uses the dark or the light mode by placing a <script> tag in the <head> of our HTML. Since the browser parses HTML line by line, the content of the script tag will precede the rendering the rest of the content:

window.matchMedia('(prefers-color-scheme: dark)').matches
? (document.documentElement.className = 'dark')
: (document.documentElement.className = 'light');

In the above document.documentElement refers to the root html node; therefore the code above will either set the dark or the light mode upon initial load. This will be either overwritten or not, depending on the actual user selection by the JavaScript code discussed earlier in this article. Using this method, the flicker/flashing will disappear.

Conclusion

Light and dark theme variants can be a great addition to websites, but some careful thought needs to be placed into implementing it in the right way. This article discussed both the two-state and the three-state theme switch implementations. If you are in a position to add the system level setting as a reset, please do so, because otherwise, you will lock your visitors in a specific mode, and they won't be able to change that setting back in an easy way to the system setting.