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, but that’s the only downside to this method imo.

~

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 always put a smile on my face. Thanks!

☕️ Buy me a Coffee (€4)

Published by Bramus!

Bramus is a Freelance Web Developer from Belgium. 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 …)

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.