1 <!-- subject: Dark theme with media queries, {CSS} and {JavaScript} -->
2 <!-- date: 2021-03-28 03:42:19 -->
3 <!-- categories: Site News, Articles, Techblog -->
4 <!-- tags: html, css, javascript -->
7 <img src=/d/news-day-night.webp width=
1000 height=
200
8 alt=
"Split view of Tower Bridge during the day and at night.">
9 <figcaption>(photo by
<a href=
"https://www.matel.tv/?wix-vod-video-id=803b455b482348dbaccbb924f575a50c&wix-vod-comp-id=comp-jct22gi6">Franck Matellini
</a>)
</figcaption>
12 <p>No, your eyes are not deceiving you. This website has gone through
13 a redesign and in the process gained a dark mode. Thanks to media queries,
14 the darkness should commence automatically according to reader’s system
15 preferences (as reported by the browsers). You can also customise this
16 website in settings panel in top right (or bottom right).
18 <p>What are media queries? And how to use them to adjust website’s appearance
19 based on user preferences? I’m glad you’ve asked, because I’m about to
20 describe the CSS and JavaScript magic that enables this feature.
23 <h2>Media queries overview
</h2>
25 <pre class=fr
style=
"--w:30em">
26 body { font-family: sans-serif; }
28 body { font-family: serif; }
32 <p>Media queries grew from the
<code>@media
</code> rule present since the
33 inception of CSS. At first it provided a way to use different styles
34 depending on a device used to view the page. Most commonly used
<dfn>media
35 types
</dfn> where
<code>screen
</code> and
<code>print
</code> as seen in the
36 example on the right. Over time the concept evolved into general
<dfn>media
37 queries
</dfn> which allow checking other aspects of the user agent such as
38 display size or browser settings. A simple stylesheet respecting reader’s
39 preferences might be as simple as:
43 /*
<i>Black-on-white by default
</i> */
47 @media (prefers-color-scheme: dark) {
48 /*
<i>White-on-black if user prefers dark colour scheme
</i> */
56 <p>That’s enough to get us started but not all browsers support that feature or
57 provide a way for the user to specify desired mode. For example, without
58 a desktop environment Chrome will report light theme preference and Firefox
59 users need to go deep into the bowels of
<code>about:config
</code> to
60 change
<code>ui.systemUsesDarkTheme
</code> flag if they are fond of darkness.
61 To accommodate such situations, it’s desirable to provide a JavaScript toggle
62 which defaults to option specified in system settings.
64 <p>Fortunately, media can be queried through JavaScript and herein I’ll describe
65 how it’s done and how to marry theme switching with browser preferences
66 detection. TL;DR version is to
67 grab
<a href=
"https://files.mina86.com/czesiu.html">a demonstration HTML
68 file
</a> which includes a fully working CSS and JavaScript code that can be
69 used to switch themes on a website.
75 <h2>Theme mode switch
</h2>
77 <p>Firstly we need a way to toggle colour themes from JavaScript. One common
78 approach is adding or removing classes to document’s root element. Those
79 classes are then used within stylesheets to apply different colours depending
80 on the choice. For example, aforementioned example of the media queries could
81 be rewritten as follows:
94 <p>With such stylesheet prepared what remains is adding a function to switch
95 <code>html
</code> element’s class name between
<code>light
</code>
99 /**
<i>Enables or disables dark mode depending on the argument. The
</i>
100 <i>change is done by adding or removing ‘light’ and ‘dark’ CSS
</i>
101 <i>classes to document’s root element.
</i> */
102 const setDarkMode = (dark = true) =
> {
103 const lst = document.documentElement.classList;
104 lst.toggle('light', !dark);
105 lst.toggle('dark', dark);
108 /**
<i>Returns whether dark mode is currently enabled.
</i> */
109 const isDarkMode = () =
> document.documentElement.classList.contains('dark');
112 <p>All of this is enough to add a dark mode toggle button on a website; for
113 example one such as the following:
116 <a
href=
"#" onclick=
"setDarkMode(!isDarkMode());
117 return false">Toggle Dark Mode
</a
>
120 <p>There are other ways of switching website themes. Another possibility is to
121 selectively disable or add stylesheets corresponding to different themes. The
122 exact implementation doesn’t matter for our purposes; what’s important is that
123 the technique is client-side and provides method for setting and returning
124 current mode:
<code>setDarkMode
</code> and
<code>isDarkMode
</code>
128 <h2>Respecting user preferences
</h2>
130 <p>The missing part is getting results of CSS media query. Fortunately, there’s
131 a function designed to provide exactly that:
<code>window.matchMedia
</code>.
132 It takes the query list as a string argument and returns object with
133 a property called
<code>matches
</code> indicating whether user agent fits the
134 query or not. The method is sufficient to initialise the theme to match
135 user’s preferences; for example:
139 const query = window.matchMedia('(prefers-color-scheme: dark)');
140 setDarkMode(query.matches);
144 <p>With this approach, when page loads its appearance is set to fit user’s
145 system settings (as indicated by the browser). Alas, the website doesn’t
146 respond to the preferences being changed
<em>after
</em> the page has been
147 loaded. This is in contrast to the
<code>@media
</code> rule which causes
148 styles to be recomputed whenever necessary.
150 <p>Fortunately there is a way to replicate CSS behaviour in JavaScript. Object
151 returned by the
<code>matchMedia
</code> function is an event target and
152 receives
<code>change
</code> events whenever the result of the query — you’ve
153 guessed it — changes. With a listener attached, code can update the theme in
154 response to preferences being altered:
158 const query = window.matchMedia('(prefers-color-scheme: dark)');
159 setDarkMode(query.matches);
160 query.addEventListener('change', _ =
> {
161 setDarkMode(query.matches);
167 <h2>Saving the choice
</h2>
169 <p>With steps described so far, the website defaults to whatever theme browser
170 reports as preferred by the user and user can toggle the dark mode if they so
171 desire. However, if they do toggle the mode and reload the page, the choice
172 is forgotten. This does not make for a good user experience.
174 <p>In the olden days cookies were the solution to such problems. While they
175 were envisioned as a way for server to save information in the browser, with
176 advent of JavaScript they could also be used by the client code. Modern and
177 a more convenient approach is to use
<code>window.localStorage
</code> instead.
178 It provides
<code>getItem
</code>,
<code>setItem
</code>
179 and
<code>removeItem
</code> methods which do exactly what their names imply.
181 <p>Incorporating local storage into the code is as simple as an apple pie.
182 Firstly, when page loads we need to consult the saved value by checking result
183 of
<code>window.localStorage.getItem('dark-theme')
</code> call. If
184 it’s
<code>null
</code> there is no saved setting and media query result should
185 be used; otherwise, enable dark mode if the result is
<code>'yes'
</code>.
186 Secondly, when toggling dark mode, we need to save the setting by
187 invoking
<code>window.localStorage.setItem('dark-theme', dark ? 'yes' :
191 <h2>Preserving explicit user choices
</h2>
193 <p>This leaves one final issue: the inconsistency of whether explicit user
194 choice made on the website or preferences reported via media query take
195 precedence. When page loads, the explicit choice will dictate the theme. On
196 the other hand, when browser settings are changed after the page has been
197 loaded, that change will take precedence.
199 <p>Addressing this is a matter of introducing a variable that remembers
200 whether user has made an explicit choice. The variable would be set on page
201 load according to whether the local storage preference was found and when user
202 changes settings on the website. This is best paired with a feature to reset
203 customisation which would make the website act as if no user choice was made.
205 <p>With all those constraints, the overall behaviour of the implementation
206 should be as follows:
209 <li>On page load, read local storage to retrieve user’s customisation for the
210 website if one has been made on previous visit. If present, set theme
211 according to it and remember it has been user’s choice. Otherwise, set
212 theme according to the media query.
213 <li>When media query changes check if user has made a choice. If they have,
214 ignore the event; otherwise set theme according to the media query.
215 <li>When user changes website settings, set theme accordingly, save the choice
216 in local storage and remember that user has made a choice.
217 <li>When user resets customisation, set theme according to media query, remove
218 setting from local storage and forget that user has made an explicit choice.
221 <p>It’s worth considering that ‘on page load’ should happen as soon as possible.
222 Preferably, code setting the initial theme would be included near the top of
223 the HTML code of the page. Putting it at the end or using deferred loading
224 for such initialisation script may result in the page flashing when user’s
225 setting from local storage or media query is applied.
230 <p>Below is the code implementing the described behaviour. It is complete
231 except for the
<code>setDarkMode
</code> and
<code>isDarkMode
</code> functions
232 which can either be copied from the top of the page or written from scratch if
233 a different way for switching themes is
234 desired.
<a href=
"https://files.mina86.com/czesiu.html">A demonstration
235 document
</a> is also available.
238 const setDarkMode = (dark = true) { /*
<i>…
</i> */; };
239 const isDarkMode = () =
> { return /*
<i>…
</i> */; };
241 /**
<i>Whether the user has explicitly chosen scheme to use. If true,
</i>
242 <i>changes to the ‘prefers-color-scheme’ media query will be ignored.
</i> */
243 let darkModeUserChoice = false;
245 /**
<i>The media query result for user’s ‘prefers dark scheme’ choice.
</i> */
246 const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
248 /*
<i>Read ‘dark-theme’ setting from local storage and set mode according
</i>
249 <i>to its value (if present) or result of the ‘prefers dark scheme’ media
</i>
250 <i>query (otherwise). Furthermore, listen to changes to the media query
</i>
251 <i>result and updates the page unless user has chosen a theme
</i>
252 <i>explicitly.
</i> */
254 let value = window.localStorage.getItem('dark-theme');
255 darkModeUserChoice = value != null;
256 value = darkModeUserChoice ? value == 'yes'
257 : darkModeMediaQuery.matches;
259 darkModeMediaQuery.addEventListener('change', _ =
> {
260 if (!darkModeUserChoice) {
261 setDarkMode(darkModeMediaQuery.matches);
266 /**
<i>Sets user choice of the dark theme preference and enables or disables
</i>
267 <i>dark theme accordingly. With an explicit user choice the result of
</i>
268 <i>color scheme preference media query will no longer be taken into
</i>
269 <i>account when choosing whether to enable dark theme.
</i> */
270 const makeDarkModeUserChoice = dark =
> {
272 darkModeUserChoice = true;
273 window.localStorage.setItem(
274 'dark-theme', isDarkMode() ? 'yes' : 'no');
277 /**
<i>Resets user choice of the dark theme preference. Instead, the
</i>
278 <i>dark theme mode will be set based on the result of the color
</i>
279 <i>scheme preference media query.
</i> */
280 const resetDarkModeUserChoice = () =
> {
281 darkModeUserChoice = false;
282 window.localStorage.removeItem('dark-theme');
283 setDarkMode(darkModeMediaQuery.matches);
287 <a
href=
"#" onclick=
"makeDarkModeUserChoice(!isDarkMode());
288 return false">Toggle dark mode
</a
>
289 <a
href=
"#" onclick=
"resetDarkModeUserChoice();
290 return false">Reset customisation
</a
>