Dark theme with media queries, CSS and JavaScript

Posted by Michał ‘mina86’ Nazarewicz on 28th of March 2021

Split view of Tower Bridge during the day and at night.
(photo by Franck Matellini)

No, your eyes are not deceiving you. This website has gone through a redesign and in the process gained a dark mode. Thanks to media queries, the darkness should commence automatically according to reader’s system preferences (as reported by the browsers). You can also customise this website in settings panel in top right (or bottom right).

What are media queries? And how to use them to adjust website’s appearance based on user preferences? I’m glad you’ve asked, because I’m about to describe the CSS and JavaScript magic that enables this feature.

Media queries overview

body { font-family: sans-serif; }
@media print {
	body { font-family: serif; }
}

Media queries grew from the @media rule present since the inception of CSS. At first it provided a way to use different styles depending on a device used to view the page. Most commonly used media types where screen and print as seen in the example on the right. Over time the concept evolved into general media queries which allow checking other aspects of the user agent such as display size or browser settings. A simple stylesheet respecting reader’s preferences might be as simple as:

body {
	/* Black-on-white by default */
	background: #fff;
	color: #000;
}
@media (prefers-color-scheme: dark) {
	/* White-on-black if user prefers dark colour scheme */
	body {
		background: #000;
		color: #fff;
	}
}

That’s enough to get us started but not all browsers support that feature or provide a way for the user to specify desired mode. For example, without a desktop environment Chrome will report light theme preference and Firefox users need to go deep into the bowels of about:config to change ui.systemUsesDarkTheme flag if they are fond of darkness. To accommodate such situations, it’s desirable to provide a JavaScript toggle which defaults to option specified in system settings.

Fortunately, media can be queried through JavaScript and herein I’ll describe how it’s done and how to marry theme switching with browser preferences detection. TL;DR version is to grab a demonstration HTML file which includes a fully working CSS and JavaScript code that can be used to switch themes on a website.

Theme mode switch

Firstly we need a way to toggle colour themes from JavaScript. One common approach is adding or removing classes to document’s root element. Those classes are then used within stylesheets to apply different colours depending on the choice. For example, aforementioned example of the media queries could be rewritten as follows:

.light body {
	background: #fff;
	color: #000;
}
.dark body {
	background: #000;
	color: #fff;
}

With such stylesheet prepared what remains is adding a function to switch html element’s class name between light to dark:

/** Enables or disables dark mode depending on the argument.  The
    change is done by adding or removing ‘light’ and ‘dark’ CSS
    classes to document’s root element. */
const setDarkMode = (dark = true) => {
	const lst = document.documentElement.classList;
	lst.toggle('light', !dark);
	lst.toggle('dark', dark);
}

/** Returns whether dark mode is currently enabled. */
const isDarkMode = () => document.documentElement.classList.contains('dark');

All of this is enough to add a dark mode toggle button on a website; for example one such as the following:

<a href="#" onclick="setDarkMode(!isDarkMode());
                     return false">Toggle Dark Mode</a>

There are other ways of switching website themes. Another possibility is to selectively disable or add stylesheets corresponding to different themes. The exact implementation doesn’t matter for our purposes; what’s important is that the technique is client-side and provides method for setting and returning current mode: setDarkMode and isDarkMode respectively.

Respecting user preferences

The missing part is getting results of CSS media query. Fortunately, there’s a function designed to provide exactly that: window.matchMedia. It takes the query list as a string argument and returns object with a property called matches indicating whether user agent fits the query or not. The method is sufficient to initialise the theme to match user’s preferences; for example:

(() => {
	const query = window.matchMedia('(prefers-color-scheme: dark)');
	setDarkMode(query.matches);
})();

With this approach, when page loads its appearance is set to fit user’s system settings (as indicated by the browser). Alas, the website doesn’t respond to the preferences being changed after the page has been loaded. This is in contrast to the @media rule which causes styles to be recomputed whenever necessary.

Fortunately there is a way to replicate CSS behaviour in JavaScript. Object returned by the matchMedia function is an event target and receives change events whenever the result of the query — you’ve guessed it — changes. With a listener attached, code can update the theme in response to preferences being altered:

(() => {
	const query = window.matchMedia('(prefers-color-scheme: dark)');
	setDarkMode(query.matches);
	query.addEventListener('change', _ => {
		setDarkMode(query.matches);
	});
})();

Saving the choice

With steps described so far, the website defaults to whatever theme browser reports as preferred by the user and user can toggle the dark mode if they so desire. However, if they do toggle the mode and reload the page, the choice is forgotten. This does not make for a good user experience.

In the olden days cookies were the solution to such problems. While they were envisioned as a way for server to save information in the browser, with advent of JavaScript they could also be used by the client code. Modern and a more convenient approach is to use window.localStorage instead. It provides getItem, setItem and removeItem methods which do exactly what their names imply.

Incorporating local storage into the code is as simple as an apple pie. Firstly, when page loads we need to consult the saved value by checking result of window.localStorage.getItem('dark-theme') call. If it’s null there is no saved setting and media query result should be used; otherwise, enable dark mode if the result is 'yes'. Secondly, when toggling dark mode, we need to save the setting by invoking window.localStorage.setItem('dark-theme', dark ? 'yes' : 'no') code.

Preserving explicit user choices

This leaves one final issue: the inconsistency of whether explicit user choice made on the website or preferences reported via media query take precedence. When page loads, the explicit choice will dictate the theme. On the other hand, when browser settings are changed after the page has been loaded, that change will take precedence.

Addressing this is a matter of introducing a variable that remembers whether user has made an explicit choice. The variable would be set on page load according to whether the local storage preference was found and when user changes settings on the website. This is best paired with a feature to reset customisation which would make the website act as if no user choice was made.

With all those constraints, the overall behaviour of the implementation should be as follows:

  • On page load, read local storage to retrieve user’s customisation for the website if one has been made on previous visit. If present, set theme according to it and remember it has been user’s choice. Otherwise, set theme according to the media query.
  • When media query changes check if user has made a choice. If they have, ignore the event; otherwise set theme according to the media query.
  • When user changes website settings, set theme accordingly, save the choice in local storage and remember that user has made a choice.
  • When user resets customisation, set theme according to media query, remove setting from local storage and forget that user has made an explicit choice.

It’s worth considering that ‘on page load’ should happen as soon as possible. Preferably, code setting the initial theme would be included near the top of the HTML code of the page. Putting it at the end or using deferred loading for such initialisation script may result in the page flashing when user’s setting from local storage or media query is applied.

The code

Below is the code implementing the described behaviour. It is complete except for the setDarkMode and isDarkMode functions which can either be copied from the top of the page or written from scratch if a different way for switching themes is desired. A demonstration document is also available.

const setDarkMode = (dark = true) { /*  */; };
const isDarkMode = () => { return /*  */; };

/** Whether the user has explicitly chosen scheme to use.  If true,
    changes to the ‘prefers-color-scheme’ media query will be ignored. */
let darkModeUserChoice = false;

/** The media query result for user’s ‘prefers dark scheme’ choice. */
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

/* Read ‘dark-theme’ setting from local storage and set mode according
   to its value (if present) or result of the ‘prefers dark scheme’ media
   query (otherwise).  Furthermore, listen to changes to the media query
   result and updates the page unless user has chosen a theme
   explicitly. */
(() => {
	let value = window.localStorage.getItem('dark-theme');
	darkModeUserChoice = value != null;
	value = darkModeUserChoice ? value == 'yes'
	                           : darkModeMediaQuery.matches;
	setDarkMode(value);
	darkModeMediaQuery.addEventListener('change', _ => {
		if (!darkModeUserChoice) {
			setDarkMode(darkModeMediaQuery.matches);
		}
	});
})();

/** Sets user choice of the dark theme preference and enables or disables
    dark theme accordingly.  With an explicit user choice the result of
    color scheme preference media query will no longer be taken into
    account when choosing whether to enable dark theme. */
const makeDarkModeUserChoice = dark => {
	setDarkMode(dark);
	darkModeUserChoice = true;
	window.localStorage.setItem(
		'dark-theme', isDarkMode() ? 'yes' : 'no');
};

/** Resets user choice of the dark theme preference.  Instead, the
    dark theme mode will be set based on the result of the color
    scheme preference media query. */
const resetDarkModeUserChoice = () => {
	darkModeUserChoice = false;
	window.localStorage.removeItem('dark-theme');
	setDarkMode(darkModeMediaQuery.matches);
};
<a href="#" onclick="makeDarkModeUserChoice(!isDarkMode());
                     return false">Toggle dark mode</a>
<a href="#" onclick="resetDarkModeUserChoice();
                     return false">Reset customisation</a>