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

☕️ Buy me a Coffee (€4)

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

Dark Mode and Variable Fonts

Robin Rendle writing for CSS-Tricks, on leveraging Variable Fonts when implementing a Dark Mode:

Oddly enough, these two bits of text [shown above] are actually using the same font-weight value of 400. But to my eye, the white text looks extra bold on a black background.

How do we fix this?

Well, this is where variable fonts come in! We can use a lighter font weight to make the text easier to read whenever dark mode is active

Like so:

body {
  font-weight: 400;
}

@media (prefers-color-scheme: dark) {
  body {
    font-weight: 350;
  }
}

Sidenote: 😍 Yanone Kaffeesatz — Back in 2005 we used this as the company font for the company I then worked at. Liked it from the moment I first saw it.

~

Depending on the design, there’s more you can do to adjust for dark mode with variable fonts besides adjusting weight. The Louvette font for example has an explicit passage on this:

With the Louvette variable fonts you can adjust the hairline axis (yopq) independently. The is particularly useful if you have light text on a dark background and need to thicken the hairlines to avoid ink from filling in, or on screen for “dark mode” settings.

~

Dark Mode and Variable Fonts →

💡 Besides tweaking colors and fonts, you might also want to tweak your images and even your favicon for Dark Mode display.

How to Design Delightful Dark Mode Themes

When done well, dark themes have many benefits. They reduce eyestrain. They are easier to read in low light. And, depending on the screen, they can greatly reduce battery consumption.

However, it is difficult to create a delightful dark theme. We cannot simply reuse our colors or invert our shades. If we do, we will achieve the opposite of what we want: we will increase eyestrain and make it harder to read in low light. We may even break our information hierarchy.

The stuff mentioned should help you to create readable, balanced, and delightful “Dark Mode” themes.

How to design delightful dark themes →

🔥 If you’re wondering how to easily implement these in your JavaScript application, I’ve done an extensive writeup on the subject. All major frameworks (React, Vue, Angular, etc.) are covered!

Emulate Dark Mode using Chrome DevTools

Coming to the next version of Chrome is a way to emulate “Dark Mode” using the DevTools. With the DevTools open and focused, hit SHIFT+CMD+P and choose “Emulate CSS prefers-color-scheme: dark from the menu

You can also access the option via the Rendering panel.

SVG favicons in Chrome

A commit that landed in Chromium (and which will be available in Chrome 80) is support for SVG favicons. 🎉

🦊 Firefox already has support for SVG favicons, ever since version 41

Since most (all?) browsers always make a request to favicon.ico you can also serve an SVG at that location with the image/svg+xml MIME Type – As per tip by Mathias

Above that it’s also possible to use prefers-color-scheme in your SVG icon, thus supporting Light/Dark Mode:

🐛 There’s on small bug though: you need to refresh the page after changing to/from Dark Mode for the favicon to change.

Dark Mode Favicon Demo →

Implementing Dark Mode on adactio.com

Jeremy recently implemented “Dark Mode” on his site. Tanks to CSS Custom Properties the implementation is pretty straightforward (also see my writeup here).

But of course, Jeremy added some extra details that make the difference:

  1. In Dark Mode images are toned down to make ‘m blend in better, as detailed by Melanie Richards:

    @media (prefers-color-scheme: dark) {
      img {
        filter: brightness(.8) contrast(1.2);
      }
    }
  2. Using the picture element, and media queries on the various source‘s contained, you can ship a dark version of an image (such as map) to the visitor:

    <picture>
      <source media="prefers-color-scheme: dark" srcset="https://api.mapbox.com/styles/v1/mapbox/dark-v10/static...">
      <img src="https://api.mapbox.com/styles/v1/mapbox/outdoors-v10/static..." alt="map">
    </picture>

Implementing Dark Mode on adactio.com →

Take both Light and Dark Mode screenshots with Puppeteer

dark-mode-screenshot is a Puppeteer script to take screenshots of both the light and dark mode versions of a website.

$ npx dark-mode-screenshot --url https://googlechromelabs.github.io/dark-mode-toggle/demo/index.html --output screenshot --fullPage

Works in somewhat odd way first requiring the OS to have dark mode enabled (?), and then launch Chromium:

  1. Once with prefers-color-scheme disabled (using --disable-blink-features=MediaQueryPrefersColorScheme)
  2. Once with Dark Mode force enabled (using --force-dark-mode)

dark-mode-screenshot (GitHub) →

❓ New to Dark Mode? No worries, this post on CSS Color Scheme Queries has got you covered.

CSS Color Scheme Queries (“Dark Mode CSS”)

Next to Safari 12.1 earlier this month, Firefox 67 now also supports “CSS Color Scheme Queries”.

The prefers-color-scheme media feature allows sites to adapt their styles to match a user’s preference for dark or light color schemes, a choice that’s begun to appear in operating systems like Windows, macOS and Android.

Chrome will support + enable it by default in Chrome 76 (the current Canary build at the time of writing)

~

Defining a “Dark Mode” for your websites becomes really easy when you combine prefers-color-scheme with CSS Custom Properties (“CSS Variables”):

:root {
    color-scheme: light dark;
    --special-text-color: hsla(60, 100%, 50%, 0.5);
    --border-color: black;
}

@media (prefers-color-scheme: dark) {
    :root {
        --special-text-color: hsla(60, 50%, 70%, 0.75);
        --border-color: white;
    }
}

.special {
    color: var(--special-text-color);
    border: 1px solid var(--border-color);
}

~

If you’re too lazy, then you can somewhat fake it by abusing mix-blend-mode: difference;, but it’s not perfect. Here’s an adjusted snippet, which injects the hack on the body using ::before:

@media (prefers-color-scheme: dark) {
    body::before {
        content: '';
        display: block;
        width: 100vw;
        height: 100vh;
        position: fixed;
        top: 0;
        left: 0;
        background: white;
        mix-blend-mode: difference;
        z-index: 1;
        pointer-events: none;
    }
}

~

A nice touch of Safari is that its DevTools also change when Dark Mode is enabled:

WebKit: Dark Mode Support in WebKit →
WebKit: Dark Mode Support in Web Inspector →Firefox 67: Dark Mode CSS, WebRender, and more →