How to implement a fool-proof dark mode

Published July 15, 2019
.* :☆゚

I think it’s fair to say dark mode is one of 2019’s biggest design patterns, especially amongst developers. Despite it’s popularity, dark mode implementation can be iffy, especially on purely static sites where dark mode is more likely to be found.


What is dark mode?

Dark mode simply means inverting light and dark colours on a website. Mac OS recently implemented dark mode in its settings, and the @media (prefers-color-scheme: dark) media query is slowly being updated in current browsers.

If you want a working example, click on the unicorn on this site to toggle the themes (to the left if you’re reading this on desktop, in the menu if you’re reading on mobile).


Adding dark mode to a website sounds trivial. In my opinion, it is not.

Sure, you can use CSS checkbox hacks if you want dark mode on a per page basis. If you’re using a JS based app you can probably more easily set the state across multiple pages. If you are using pure HTML and CSS like I am though, theme switching becomes a lot more finicky. And that’s not even mentioning the additional styles that comes with re-theme-ing a site, implemented in a such a way so that it doesn’t compromise the overall site design.

Many boilerplates and static site generators shepherd people to write JavaScript in separate files which are then loaded in either the head or footer. This is not necessarily a bad thing! It just means that since these build processes may be new and unfamiliar, and given the fact that many of these generators are relatively recent and developers are likely experimenting with different processes, it can be easy to forget that loading in external assets takes a few extra seconds/milliseconds in bandwidth to load.

In web development, milliseconds is pretty significant especially if you’re using JavaScript to set and remember a user’s preference. If the JavaScript takes too long to load, the user’s preference may load in way later than your default styles, meaning you’ll get a flash of the opposite theme while the rest of the page is loading. This is especially noticeable when you’re navigating from one page to another.

Here is an example from my own website a few deploys back:


I’ve come across theme flashing many times visiting other people’s websites, some of whom are developers I greatly admire. It was even a bug on this site for the longest time, but I was too lazy to fix it until today because personally, I don’t particularly rate dark mode and just threw it in here for fun and as a little easter egg.

To many people who don’t care to look into it too much, these minute changes between pages will be unnoticeable or at the very least passable.

To me however, I see it as a huge caveat of dark mode on static sites, and seeing how often I notice this bug, it makes me wonder why people even bother implementing it to begin with. Coupled with my slow internet connection and Netlify not caching assets by default, it usually becomes obvious to me when a website’s dark mode has not been implemented properly.

I call this phenomenon the flash of opposite colour-scheme.

FOOC.

YUP.

It is now a thing.


So what’s a person gotta do to get that perfect dark mode?

There are many ways to implement dark mode, but to do it so that user’s preferences are remembered across the website, you’ll need to use JavaScript.

Using the localstorage property, we can ensure the implementation is fool-proof regardless of internet speed or page weight by doing two things:

  • Putting a conditional inline within the markup(in the head) to ensure it runs as soon as the page loads.
  • Checking the localstorage item and nothing else- not the body class, inline CSS blocks etc.

Relying on JavaScript dependent classes for conditionals is really what tripped me up to begin with- I was just too lazy and inexperienced to question why it was a bad idea (it’s a bad idea in this scenario because the order in which functions run then becomes important, leading to more code overhead and confusion).

My initial setup of this site had me serving both my CSS and JS from external files. When I realised my initial dark mode implementation had FOOC, I needed to fix it and went googling to see how other people did it.

I eventually came across this technique by Pat David, although this was honestly the only article I could find which addressed the issue and explained a solution (either devs don’t care too much about this that much or they already know how to do it properly and just didn’t blog about it).

His technique involved using inline CSS and JavaScript alongside the disabled JavaScript property.

I tried and tested this technique for a while, and thought it worked perfectly until I realised I could still see minute changes between text between page loads. We’re talking milliseconds. But I could still notice it and it was annoying. My set up and styles were admittedly a lot more complex than his use-case. The reason it failed for me though was not because of the technique, but for the same reason mine failed to begin with. The assets were being rendered and utilised in a less than ideal order and which ended up confusing me even more.


Much experimenting later, my hopefully fool-proof implementation has ended up looking something like this:

1

Style the default theme of the site using CSS properties set on the :root.

2

Add some alternate properties using a different class.

 1    :root {
 2        --theme: #574d4a;
 3        --bronze: #a07c59;
 4        --darkbronze:#886547;
 5        --grey:#edecf7;
 6
 7        .dark & {
 8            --theme: #d1d0dc;
 9            --darkbronze:#ca8057;
10            --bronze: #9f9fc4;
11            --grey:#4e4d63;
12        }
13    }

3

Add a dark mode button in the markup.

1<button id="unicorn"><span class="screen-reader-text">Toggle dark mode</span></button>

4

Attach a click event to the button. Using an external file is fine since this functionality is not dependent on how a page loads.

 1const unicorn = document.getElementById('unicorn'),
 2      root = document.getElementsByTagName( 'html' )[0];
 3unicorn.addEventListener('click', function () {
 4    //check if dark mode has been activated yet
 5    //if it has not, set up the dark mode!
 6    // at the same time, toggle the theme classes on the html tag
 7    //sidenote: I don't recommend applying custom classes to the body tag. If you're theme-ing a site you want the highest specificity possible
 8
 9    if( localStorage.getItem('theme') !== 'dark' ){
10        localStorage.setItem('theme', 'dark');
11        root.classList.remove('light');
12        root.classList.add('dark');
13    } else {
14        localStorage.setItem('theme', 'light');
15        root.classList.remove('dark');
16        root.classList.add('light');
17    }
18}, false);

5

Add an inline conditional before the closing head tag to check if a preference has already been set. Make sure to run it against localstorage, not JavaScript dependent CSS classes.

 1<script type='text/javascript'>
 2	var root = document.getElementsByTagName('html')[0];
 3	if (localStorage.getItem('theme') === 'dark') {
 4		root.classList.remove("light");
 5		root.classList.add("dark");
 6	} else {
 7		root.classList.remove("dark");
 8		root.classList.add("light");
 9	}
10</script>

And that’s pretty much it.

The code is simple enough, but it’s how and where it runs that matters.

With this method you can then use the .dark class to style more complex things like scrollbars and placeholders in external stylesheets using Sass.

1::-webkit-scrollbar {
2	width: 15px;
3	@extend %lightbg;
4
5	.dark & {
6		@extend %darkbg;
7	}
8}

The advantage of this technique is that it is by default designed to degrade gracefully. Since all the dark mode styles are dependent on the JavaScript-dependent .dark class, if JavaScript is not enabled in the browser for whatever reason, the site will simply use it’s default styles.

All in all, it was quite a journey to get to this point but now I understand how pages load better, and I can sleep easy knowing the dark mode on my site works as intended.