The Quest for the Perfect Dark Mode Toggle, using Vanilla JavaScript

In The Quest for the Perfect Dark Mode, Joshua W Comeau extensively explains how he has implemented the Dark Mode Toggle on his Gatsby-powered website. It follows the system’s light/dark mode preference, but also allows for an override which he persists in localStorage.

The flow to decide if Dark Mode should be used or not is as follows:

Based upon this flow, I decided to create a Vanilla JS version. Here’s a Pen with the final result to see where we’re headed at:

🤔 One thing that I personally find missing from Joshua’s implementation is a way to reset the override. To not diverge too much from his implementation I’ve added the reset option via a separate link. What ideally could be done is to use radio buttons (instead of a checkbox) to change between auto, light, and dark.

🎨 Before you continue: Note that this post goes into detail on how to implement a Dark Mode Toggle, not on how to design your Dark Mode theme. For the latter I refer to How to Design Delightful Dark Mode Themes.

Also note that Dark Mode goes beyond simply adjusting colors. You might also need to tweak your fonts, tweak the appearance of images (or ship entirely different ones), tweak your favicon, etc.

~

To override Dark Mode I use a data-* attribute on the html element.

<!-- No Override (e.g. follow System Preferences) -->
<html>

<!-- Force Light Mode -->
<html data-force-color-mode="light">

<!-- Force Dark Mode -->
<html data-force-color-mode="dark">

That way, I can implement Dark Mode like it’s meant to be implemented: Using CSS.

/* Light Color Scheme (Default + Override) */
:root,
:root[data-force-color-mode="light"] {
	color-scheme: light dark;
	--text-color: #000;
	--background-color: #fff;
	--link-color: #00f;
}

/* Dark Color Scheme (System Preference) */
@media (prefers-color-scheme: dark) {
	:root {
		--text-color: #fff;
		--background-color: #333;
		--link-color: #2196f3;
	}
}
/* Dark Color Scheme (Override) */
:root[data-force-color-mode="dark"] {
	--text-color: #fff;
	--background-color: #333;
	--link-color: #2196f3;
}

😕 I know, I have to duplicate the declaration of my Dark Mode CSS Custom Properties. That’s the only downside to this method, but I (currently) don’t see any way to work around this.

~

To prevent a FOUC on load in case an override is set the same technique as Josh uses is applied: the use of a blocking script. I’ve added the snippet into the head of the page to update the html element if need be.

<html>
<head>
    <script>
        // Check if there's any override. If so, let the markup know by setting an attribute on the <html> element
        const colorModeOverride = window.localStorage.getItem('color-mode');
        const hasColorModeOverride = typeof colorModeOverride === 'string';
        if (hasColorModeOverride) {
            document.documentElement.setAttribute('data-force-color-mode', colorModeOverride);
        }
    </script>
</head>
<body>
  …
</body>
</html>

There’s also a small piece of inline JavaScript needed to make sure the Dark Mode Checkbox shows the correct value on page load. I’ve placed this snippet directly after the markup needed for the Checkbox, so that there’s no FOUC going on.

<input type="checkbox" id="toggle-darkmode" />
<label for="toggle-darkmode"><span>Toggle Light/Dark Mode</span></label>
<script>
    // Check the dark-mode checkbox if
    // - The override is set to dark
    // - No override is set but the system prefers dark mode
    if ((colorModeOverride == 'dark') || (!hasColorModeOverride && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.querySelector('#toggle-darkmode').checked = true;
    }
</script>

~

The Dark Mode checkbox itself will show either a 🔆 or a 🌙 depending on which mode is used. This is implemented without any JavaScript.

Upon clicking it the setColorMode function will kick into action. This function is also used by a reset-link.

const setColorMode = (mode) => {
	// Mode was given
	if (mode) {
		// Update data-* attr on html
		document.documentElement.setAttribute('data-force-color-mode', mode);
		// Persist in local storage
		window.localStorage.setItem('color-mode', mode);
		// Make sure the checkbox is up-to-date
		document.querySelector('#toggle-darkmode').checked = (mode === 'dark');
	}
	
	// No mode given (e.g. reset)
	else {
		// Remove data-* attr from html
		document.documentElement.removeAttribute('data-force-color-mode');
		// Remove entry from local storage
		window.localStorage.removeItem('color-mode');
		// Make sure the checkbox is up-to-date, matching the system preferences
		document.querySelector('#toggle-darkmode').checked = window.matchMedia('(prefers-color-scheme: dark)').matches;
	}
}

~

Finally, to stay in sync with the system preferences, a listener is placed on the Dark Mode Media Query. If no override is set, the toggle will follow the system preference

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addListener(() => {
	// Ignore change if there's an override set
	if (document.documentElement.getAttribute('data-force-color-mode')) {
		return;
	}

	// Make sure the checkbox is up-to-date
 	document.querySelector('#toggle-darkmode').checked = mediaQuery.matches;
});

~

That’s basically it. The Dark Mode itself is implemented using only CSS – as it should be. It’s only for the override that we must rely on JavaScript. It requires some jumping through hoops, but all-in-all it’s not that hard I must say.

Did this help you out? Like what you see?
Thank me with a coffee.

I don\'t do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

BuymeaCoffee (€4)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

Published by Bramus!

Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)

Unless noted otherwise, the contents of this post are licensed under the Creative Commons Attribution 4.0 License and code samples are licensed under the MIT License

Join the Conversation

5 Comments

  1. Hello, I was testing your Dark mode on Codepen, but it seems that it doesn’t work in the Incognito Window(I only tested in Chrome), means it is working perfectly fine normally, but in Incognito mode, it is not persistent. Please do fix it!

  2. Hi, came across this and it seem to be just what I was looking for. But unlike with Safari or Chrome, the dark mode detection seems to be not working properly with Firefox (latest version) on page load. I get a sun icon with dark mode enabled. 🤔 Any ideas?

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.