How I built a dark mode toggle

This site has had dark and light modes for a while, but it never offered any choice. If your system preference was “dark”, that's what you would get (unless you used fancy browser flags). Yesterday, I implemented a dark mode toggle, so that you can override that at your leisure. In this post I will talk about some of the choices I made along the way. Basically, it's a long winded way of saying why it took me so long.

This is fun to fiddle around with, but really, it seems to me that all websites have this requirement, so why isn't this built into browsers? In other words, I agree with Bramus Van Damme, dark mode toggles should be a browser feature. His post has more reasons: however you support light and dark modes, you will likely end up with code duplication somewhere, and toggles that live in developer land require JavaScript.

The choices

HTML

There are a lot of HTML elements that you can use to give users a choice between one out of many options. Radio buttons and <select>s come to mind. A checbox could be an option too… if there is only one option, checkboxes too allow just the one option.

I started out with radio buttons as I thought that was clever, and even ended up building the control all the way from coding a form a fieldset, legend and options. I went ahead to visually hide the control itself and then use the :checked pseudo class to only display the option that wasn't picked. In other words, when dark mode was on, the light mode option was visible and vice versa.

screen recording of dev tools and the toggle, in the dev tools the code is as described, with a form, fieldset, legend, options and svgs It's a whole form!

It must have been late, because only when I started testing this with keyboard, a sanity check I do with all controls, I realised it didn't feel right. With a keyboard, you would need arrow keys to pick the other option and that feels weird, especially for a control that is styled to not look like radio buttons. I guess the lesson is: never style elements too far from their original purpose.

I ended up going for just the one button element. To switch styles, I would need JavaScript either way, so I would not benefit from having a checkbox in this case. In Underengineered Toggles Too, Adrian Roselli explains that the button makes sense if the control only works with JavaScript (check; we need to toggle a class or attribute to apply the CSS), if it only has true or false states, not mixed (check; we allow either light or dark) and if flipping the control immediately performs an action (check; the color scheme changes straightaway). The great thing about a button element, just for the record, is that it comes with keyboard support built-in. No extra cost.

screen recording of dev tools and the toggle, in the dev tools the code is a button It's a button!

One button, like mine, does mean that users can't explicitly choose that they want my site to follow their browser or OS setting. In his post Your dark mode toggle is broken, Kilian Valkhof makes the case for including a “system” setting that would solve this very problem.

CSS

With CSS, I made the button look like not a button. I visually hid the text inside of it. I also added an SVG icon that I applied pointer-events: none to, so that clicks on the SVG would be registered as clicks on the button.

JavaScript

In script, I wanted a couple of cases to be covered. Let's walk through the various parts of the implementation.

First, I stored the button and the query to find out if a dark color scheme is preffered, and created an empty variable for the current theme:

var button = document.querySelector('.theme-selector');
var prefersDark =  window.matchMedia('(prefers-color-scheme: dark)');
var currentTheme;

I also added a function to set a theme, which adds an attribute to the html element and stores the preference in localStorage:

function setTheme(currentTheme) {
  var pressed = currentTheme === 'dark' ? 'true' : 'false';
	
  document.documentElement.setAttribute('data-theme-preference', currentTheme);
  localStorage.setItem('theme-preference', currentTheme);
}

Then I figured out what the theme should be. First I checked if there is something in localStorage, if not if there is a preference for dark mode, and if not, I set it to default to light mode:

if (localStorage.getItem('theme-preference')) {
  currentTheme = localStorage.getItem('theme-preference');
} else if (prefersDark.matches) {
  currentTheme = 'dark';
} else {
  // default
  currentTheme = 'light';
}

We also want ways to change the theme. First off, when the user clicks the button:

button.addEventListener('click', function(event) {
  currentTheme =  document.documentElement.getAttribute('data-theme-preference') === "dark" ? "light" : "dark";
		setTheme(currentTheme);
  });

Secondly, when the user changes their theme preference:

prefersDark.addEventListener('change', function(event) {
  currentTheme = event.matches ? 'dark' : 'light';
  setTheme(currentTheme);
});

(This listens to changes in the browser's MediaQueryList, which could change when the setting is changed in the browser or the OS).

Website appearance setting, that allows to choose between Nightly theme, System theme, Light or Dark In Firefox, you can pick light or dark mode, if you don't want it to just follow the system or your choice of Firefox theme. There is no per website setting at the time of writing.

ARIA

In my book, it's ideal to only use ARIA when it is for something that:

  1. doesn't exist in HTML
  2. brings a known benefit to end users

In this case, we're using a standard button element, which exists in HTML. But there is one detail about it that doesn't exist in HTML, which is that this is the type of button that toggles. We can add this by setting the aria-pressed with a "true" or "false" value.

This brings a known benefit to end users, specifically to users of screenreaders, who will now hear this is a toggle button. For toggle buttons, it is important that the content doesn't change. As the button text, I have used “Toggle dark mode”.

If we would update the label of the button from “Turn on light” to “Turn on dark”, we kind of have a toggle (as MDN suggests), but that way, it wouldn't be recognised by the browser and set in the accessibility tree to be a toggle, and, because of that, not announced as a toggle by screenreaders.

Compromises

Code duplication

In his post, Bramus explains that when you want to build a dark mode toggle, you will end up with duplicated CSS. That is, if you both want to see color changes support the prefers-color-mode media query, and the class or attribute that you're toggling with your toggle. I went for not supporting that media query directly in CSS. Instead, I rely on my script to set the right data attribute. This means that without JavaScript, users will only see the light mode, even if their system or browser setting is to use dark mode.

Honouring the system preference

As mentioned above, my toggle doesn't have a “do whatever the system does” setting. However, when you change the system setting, it will notice and use that system setting. That is, until you change your preference again, either via the toggle or the system setting.

Many themes

The toggle I ended up with can only swap between two states. Initially I had wanted it to work for any number of states, I mean, why not have lots of different themes between light and dark? It's tricky, because how do those two settings, that the media query currently supports checks for, map to my many themes? This may be something for future iterations, probably with some way to map two specific themes to be the browser's “light” and ”dark”.

Summing up

To build a dark mode toggle is an exercise full of compromises. There are many ways that, proverbially, lead to Rome, as you can read above. I was happy enough with the current result to ship it for now, but am more than open to feedback.

Comments, likes & shares (101)

“Once that cookie is set, you’re getting that version” My example listens to changes in OS/browser setting, if they change, _that_ is set in a cookie (or in my case, localStorage to be more precise)
(if you change it while you happen to be on my blog, that is 😂)
That means that users that prefer your site in a specific theme but automatically have their OS change have to change your site back all the time. Three options solves that explicitly.
Three options solves that, but at the cost of a more complex interaction / mental overhead. I see the point, and would likely go for that it in some cases, but this feels right to me for my site
You asked for feedback 😛 Thats fair! Like you said its all compromises and trade-offs.
Yes, many thanks! Adding your post to give people more food for thought
But, doesn’t this get a flash of un-darkened content? I wanted to provide a dark mode toggle on my static website and that was the main issue I had. You get the light theme until JS is loaded?
Hm… I think you're right and it should… but I can't reproduce it when throttling connection speed in Dev Tools, but maybe my browser parses the JS too fast?
Don’t forget about User Agent settings. You can override your OS settings there.
That didnt exist yet when I wrote the article but yes. I’ve also been a big supporter of the idea that browsers should have origin-specific settings for dark/light, reduced motion and other user preference media queries.
ah, you have your JS inlined, so if the HTML is loaded the JS is also loaded. Super fast! 🙌🏼
like an animal 😂 yeah it's pretty much the only JS so didn't do a build system or anything yet
“So you’ll probably want to tell visitors your have a dark mode and let them choose which one they prefer.” I don't really think people care. I just have it set to adjust to system prefs.
do I really want to set a mode on every blog I visit? Naaah!
I explain why people want that in the paragraph directly above it. It’s fine if you don’t, whatever, but it doesn’t invalidate the reasoning. Additionally the entire article in literally about making sure there is a system prefs setting, something most sites lack.
I can't find the dark mode toggle on your site btw
I’m missing the point you’re trying to make.
You're making a huge point about dark mode toggles but you don't have it on your site
Love a dark theme, I always start with the dark and do light after
Normally, this is correct. Some browsers will hold paint long enough though (Chrome) while others might not (Safari to my knowledge). So, chances of flash is bigger in Safari. This could be solved by making the snippet render blocking from the start.
Ooh that's smart! So short that it hardly has a chance to noticeably block
thanks! will add
Just read: "How I built a dark mode toggle" hidde.blog/dark-light/
Thanks for the shout-out, Hidde!
thanks for pushing for the right thing in your post! 🙏
Me too plus a public template repo been created: github.com/iamdtms/theme-…
How I built a dark mode toggle. hidde.blog/dark-light/ via @hdv #CSS #HTML
If you want to build a dark mode toggle, then this article will be useful hidde.blog/dark-light/ Thanks you, @hdv 👍 #webdev #webdesign #darkmode
How I Built a Dark Mode Toggle, by @hdv: hidde.blog/dark-light/
How I built a dark mode toggle hidde.blog/dark-light/
🔴 How I built a dark mode toggle by Hidde de Vries @hdv #DarkMode #toggle #ARIA #accessibility #webdev hidde.blog/dark-light/
Very nice and clear read, as always!
❤️
How I built a dark mode toggle, by @hdv hidde.blog/dark-light/
And @chriscoyier is obviously thinking a lot about the best possible user experience when he implements his Dark Mode via a Smallish Script in the Head, Avoiding FARThttps://chriscoyier.net/2023/01/19/dark-mode-via-a-smallish-script-in-the-head-avoiding-fart/ Dark Mode via a Smallish Script in the Head, Avoiding FART - Chris Coyier
@dennisl You realize that that’s almost 10 years ago, right? 🤯😁
@matthiasott Yea, unfortunately 😝
@dennisl @matthiasott What's an "IE8"? 😜
@matthiasott @hdv i donʼt understand, why js is necessary, but maybe i read too fast.i want to test a combination of checkbox, media query, :not, custom properties