Dark theme with media queries, CSS and JavaScript
Posted by Michał ‘mina86’ Nazarewicz on 28th of March 2021 | (cite)
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>