Ever since I heard about the CSS media query prefers-color-scheme, I knew I would use it in any theme I designed from scratch. I wrote about using prefers-color-scheme a little while ago.

I’ve been maintaining light and dark versions of this website since the first version. It’s easy enough to do with CSS variables and the media query, but what isn’t as easy is actually letting the user choose a scheme by using a toggle on the page (overriding the OS light/dark mode setting).

But I finally figured out a way to do it that doesn’t feel too ugly, so I’ll tell you about it.

What I wanted to accomplish

This is a list of features I wanted out of this change:

  1. The website should respect the user’s OS setting (prefers-color-scheme)
  2. There’s some kind of button/toggle/switch on the page that lets the user toggle the mode, starting from whatever the current OS setting is
  3. The color mode setting has to be persistent, so cookies 🍪
  4. There has to be a way to clear the cookie so the user has the choice to go back to letting the OS decide the color scheme. Ideally this would be obvious/easy to do
  5. Everything has to work (with no visible toggle and OS setting support) with Javascript disabled
  6. Like most of the things I do on this website, it has to be relatively simple. I do not want a complicated solution

What I started with

The version that only listens to prefers-color-scheme and doesn’t do the stuff above looks like this:

 1:root {
 2    --font-family: 'Questrial-Regular', Arial, 'Helvetica Neue', Helvetica, sans-serif;
 3    --code-font-family: Consolas, monaco, monospace;
 4    --red-color: #af1414;
 5    --green-color: #3a9417;
 6    --blue-color: #1111e6;
 7    --background-color: #ffffff;
 8    --secondary-background-color: #f5f5f5;
 9    --text-color: #363636;
10    --secondary-text-color: #757575;
11    --alternate-text-color: #f7f7f7;
12    --primary-color: #010002;
13    --secondary-color: #781092;
14    --image-border-color: #383838;
15    --image-shadow: 5px 5px 15px 5px rgba(0, 0, 0, 0.2);
16    --default-border-radius: 8px;
17    --page-max-width: 1000px;
18    --about-image-size: 150px;
19    --fa-primary-color: var(--primary-color);
20    --fa-secondary-color: var(--secondary-color);
21}
22
23@media (prefers-color-scheme: dark) {
24    :root {
25        --red-color: #ec7a7a;
26        --green-color: #35a719;
27        --blue-color: #7676f7;
28        --background-color: #000000;
29        --secondary-background-color: #0e0d0e;
30        --text-color: #f0f0f0;
31        --secondary-text-color: #bdbdbd;
32        --alternate-text-color: #0e0d0e;
33        --primary-color: #e0e0e0;
34        --secondary-color: #83698b;
35        --image-border-color: #a5a5a5;
36        --image-shadow: 5px 5px 15px 5px rgba(201, 201, 201, 0.1);
37        --fa-primary-color: var(--primary-color);
38        --fa-secondary-color: var(--secondary-color);
39    }
40}



And then everything else in the CSS uses those color variables so… that’s pretty much it.

The problem is I can’t do anything from Javascript with this.

prefers-color-scheme + data-user-color-scheme

So I decided to have the html element and a data-user-color-scheme control the main set of CSS variables that the rest of the elements use. To avoid having to duplicate as little as possible (I don’t want to have to adjust any of the colors in multiple places) I specify the base set of variables in the :root element. Like this:

 1:root {
 2    --font-family: 'Questrial-Regular', Arial, 'Helvetica Neue', Helvetica, sans-serif;
 3    --code-font-family: Consolas, monaco, monospace;
 4    --default-border-radius: 8px;
 5    --page-max-width: 1000px;
 6    --narrow-page-max-width: 800px;
 7    --about-image-size: 150px;
 8    --light--red-color: #af1414;
 9    --light--green-color: #3a9417;
10    --light--blue-color: #1111e6;
11    --light--background-color: #ffffff;
12    --light--secondary-background-color: #f5f5f5;
13    --light--text-color: #363636;
14    --light--secondary-text-color: #757575;
15    --light--alternate-text-color: #f7f7f7;
16    --light--primary-color: #181818;
17    --light--secondary-color: #919191;
18    --light--image-border-color: #383838;
19    --light--image-shadow: 5px 5px 15px 5px rgba(0, 0, 0, 0.2);
20    --dark--red-color: #ec7a7a;
21    --dark--green-color: #35a719;
22    --dark--blue-color: #7676f7;
23    --dark--background-color: #000000;
24    --dark--secondary-background-color: #0e0d0e;
25    --dark--text-color: #f0f0f0;
26    --dark--secondary-text-color: #bdbdbd;
27    --dark--alternate-text-color: #0e0d0e;
28    --dark--primary-color: #e0e0e0;
29    --dark--secondary-color: #6e6e6e;
30    --dark--image-border-color: #a5a5a5;
31    --dark--image-shadow: 5px 5px 15px 5px rgba(201, 201, 201, 0.1);
32}



Then I have a little bit of duplicated code in the next part, but it’s not complete trash. I want to have a set of CSS variables set in the html element, but that set is determined by whether the media query matches AND whether data-user-color-scheme on the html element is set. It looks like this:

 1html, html[data-user-color-scheme='light'] {
 2    --red-color: var(--light--red-color);
 3    --green-color: var(--light--green-color);
 4    --blue-color: var(--light--blue-color);
 5    --background-color: var(--light--background-color);
 6    --secondary-background-color: var(--light--secondary-background-color);
 7    --text-color: var(--light--text-color);
 8    --secondary-text-color: var(--light--secondary-text-color);
 9    --alternate-text-color: var(--light--alternate-text-color);
10    --primary-color: var(--light--primary-color);
11    --secondary-color: var(--light--secondary-color);
12    --image-border-color: var(--light--image-border-color);
13    --image-shadow: var(--light--image-shadow);
14    --fa-primary-color: var(--light--primary-color);
15    --fa-secondary-color: var(--light--secondary-color);
16}
17
18html[data-user-color-scheme='dark'] {
19    --red-color: var(--dark--red-color);
20    --green-color: var(--dark--green-color);
21    --blue-color: var(--dark--blue-color);
22    --background-color: var(--dark--background-color);
23    --secondary-background-color: var(--dark--secondary-background-color);
24    --text-color: var(--dark--text-color);
25    --secondary-text-color: var(--dark--secondary-text-color);
26    --alternate-text-color: var(--dark--alternate-text-color);
27    --primary-color: var(--dark--primary-color);
28    --secondary-color: var(--dark--secondary-color);
29    --image-border-color: var(--dark--image-border-color);
30    --image-shadow: var(--dark-image-shadow);
31    --fa-primary-color: var(--dark--primary-color);
32    --fa-secondary-color: var(--dark--secondary-color);
33}
34
35@media (prefers-color-scheme: dark) {
36    html {
37        --red-color: var(--dark--red-color);
38        --green-color: var(--dark--green-color);
39        --blue-color: var(--dark--blue-color);
40        --background-color: var(--dark--background-color);
41        --secondary-background-color: var(--dark--secondary-background-color);
42        --text-color: var(--dark--text-color);
43        --secondary-text-color: var(--dark--secondary-text-color);
44        --alternate-text-color: var(--dark--alternate-text-color);
45        --primary-color: var(--dark--primary-color);
46        --secondary-color: var(--dark--secondary-color);
47        --image-border-color: var(--dark--image-border-color);
48        --image-shadow: var(--dark-image-shadow);
49        --fa-primary-color: var(--dark--primary-color);
50        --fa-secondary-color: var(--dark--secondary-color);
51    }
52}



The code above is the only part that bothers me. It will probably be the first thing to be improved the next time I work on this part.

Okay, now that all of that is set, on to the Javascript to make the toggle work.

Screenshot of the color scheme toggle (light mode)
The light bulb at the bottom of the page changes the color scheme


Screenshot of the color scheme toggle (dark mode)
There it is in dark mode


Javascript

setupColorSchemeToggle

setupColorSchemeToggle is a function I wrote that’s responsible for a few things here.

  1. Checks if the data-user-color-scheme attribute is set in the html element
  2. If it’s not set, it checks if the prefers-color-scheme media query matches ‘dark’ (using window.matchMedia)
  3. Checks if a prefers-color-scheme cookie was set (if the user has manually toggled the color scheme). If so, it updates the data attribute
  4. Then based on the previous steps, adds and configures the toggle button/icon for either light or dark mode. The configuration includes the handler function for the toggle, which includes changing the data attribute, and setting the prefers-color-scheme cookie

This is what it looks like:

 1function setupColorSchemeToggle() {
 2    const htmlElement = document.getElementsByTagName('html')[0];
 3    const colorSchemeToggle = document.getElementById('color-scheme-toggle');
 4
 5    let currentColorScheme = htmlElement.dataset.userColorScheme;
 6    if (!currentColorScheme) {
 7        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
 8            currentColorScheme = 'dark';
 9        }
10    }
11
12    const storedColorScheme = getCookie('prefers-color-scheme');
13    if (storedColorScheme) {
14        currentColorScheme = storedColorScheme;
15        htmlElement.dataset.userColorScheme = storedColorScheme;
16    }
17
18    if (currentColorScheme === 'dark') {
19        colorSchemeToggle.innerHTML = '<i class="fad fa-lightbulb-on"></i>';
20        colorSchemeToggle.onclick = function() {
21            htmlElement.dataset.userColorScheme = 'light';
22            setCookie('prefers-color-scheme', 'light');
23            setupColorSchemeToggle();
24        };
25    } else {
26        colorSchemeToggle.innerHTML = '<i class="fad fa-lightbulb"></i>';
27        colorSchemeToggle.onclick = function() {
28            htmlElement.dataset.userColorScheme = 'dark';
29            setCookie('prefers-color-scheme', 'dark');
30            setupColorSchemeToggle();
31        };
32    }
33}



This function is called in 3 different situations. Once when the page first loads (and if Javascript is disabled, it’s never run so the toggle isn’t visible), whenever the toggle is clicked, and if the OS color scheme setting changes while the page is open.

The last part is done with this code:

1window.matchMedia('(prefers-color-scheme: dark)').addListener(function(e) {
2    htmlElement.dataset.userColorScheme = e.matches ? 'dark' : 'light';
3    clearCookie('prefers-color-scheme');
4    setupColorSchemeToggle();
5});



It also clears the cookie at that point. I figured an active color scheme change was a pretty decent indication that the user is actively managing their color scheme and probably wants the page to react to it.

Speaking of clearing the color scheme cookie, I also added a link to clear the cookie on the privacy page in the section listing all the cookies the website uses.

That’s it

I hope this is useful for anyone doing any manual theme work. I’ll write more about this as I make improvements to what I have so far.