Dark mode with CSS custom properties

How do you adapt your website to accomodate the users preferred color scheme, but also lets them change it if they wish? Here is how I sovled it using CSS custom properties, and a little bit of JavaScript.

Setting up the custom properties

The first thing to do is to make sure that there is only one source of truth for the colors on your page. I have them declared in a CSS file that is included on all pages on my site. The relevant part looks like this:

html {
--red: rgb(238, 54, 24);
--red-dark: rgb(184, 44, 23);
--yellow: rgb(251, 215, 56);
--yellow-dark: rgb(176, 128, 0);
--blue-light: rgb(158, 175, 255);
--blue: rgb(53, 87, 237);
--blue-dark: rgb(34, 82, 170);
--black: rgb(46, 24, 5);
--gray-dark: rgb(59, 57, 56);
--white: rgb(255, 255, 255);
--white-off: rgb(243, 237, 220);
}

This puts all of the color values I need at the top level of my documents. I could go ahead and assign them to my elements now, and this is exactly what I had done previously, but this doesn't lend itself well to swapping the values to their dark equivalents. If I wanted the text to be white instead of black for instance, I would have to overwrite

--black: rgb(46, 24, 5);

with

--black: rgb(255, 255, 255);

which makes no sense at all. So instead I used an intermediary step, and created a bunch of properties that were more decriptive of their place of use:

body {
--background1: var(--yellow);
--background2: var(--white);
--background3: var(--white);
--text: var(--black);
--text2: var(--white);
--accent1: var(--red);
--accent2: var(--blue);
--accent2-contrast: var(--blue-dark);
--accent3: var(--black);
--accent4: var(--yellow);
--accent5: var(--yellow);
}

And then used them in my other code.

main {
background-color: var(--background1);
color: var(--text)
}

This allows me to then make the follwing delcaration, using the media query to detect if the user has set their preferred color scheme in their operating system:

@media (prefers-color-scheme: dark) {
body {
--background1: var(--gray-dark);
--background2: var(--gray-dark);
--background3: var(--yellow-dark);
--text: var(--white-off);
--text2: var(--white-off);
--accent1: var(--red-dark);
--accent2: var(--blue-dark);
--accent2-contrast: var(--blue-light);
--accent3: var(--yellow-dark);
--accent4: var(--gray-dark);
--accent5: var(--yellow-dark);
}
}

Note how all the values have been swapped for dark backgrounds and light texts. It took some fiddling around to get the colors right of course, but since my site is pretty simple it didn't take too long.

If you have a larger site you will most likely have to plan the colors schemes carefully in advance, and map out which colors will translate to which in the dark mode. And not just the colors - just because two elements have the same colors in light mode doesn't mean they should have the same color in dark mode. It more a matter of translating light elements to dark elements. For instance, in the code above, background 1 and 2 are different colors in light mode, but the same in dark mode. Some of the accents translate to their respective darker colors, but some swap color entirely.

This is all you need in order to apply a dark mode to your site based on user preference, but what if they don't like what they see?

Allowing the user to change mode

In order to allow the user to change the color scheme of the site on demand I decided to use a little bit of JavaScript. I removed the media query from the CSS and moved it to a js file instead. The CSS now looks like this:

body {
--background1: var(--yellow);
--background2: var(--white);
--background3: var(--white);
...
}

body.dark {
--background1: var(--gray-dark);
--background2: var(--gray-dark);
--background3: var(--yellow-dark);
...
}

As you can see the dark properties are now activated if the body tag has the class "dark" on it. Let's take a look at the JavaScript.

const body = document.getElementById("body");

(function detectColorScheme() {
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

// In case the media query is not supported on the users machine, do nothing
if (darkMediaQuery.media === "not all") {
return;
}

if (darkMediaQuery.matches) {
body.classList.add("dark");
}
})();

function toggleColorScheme() {
body.classList.toggle("dark");
}

First we get the body element, so that we have someting to work with. Then comes the part that the CSS previously did. We do a media query on the window object, and then look to see if this query is supported by the current browser that's being used. If it is, then we go ahead and check if the query matches. If it does, then we slap that "dark" class on the body element. Now we have set the scheme according to the user preference. So far so exactly the same as before.

Last comes a function that removes or adds the class to the element. It is executed whenever the user clicks a button, like so:

<button onClick="toggleColorScheme()">Toggle dark mode</button>;

There you have it. At the time of publishing this article, you can try it out by clicking the button in the top right corner of the page. Take a look in the dev tools and you will see the class appearing and disappearing on the body tag, and you can see the CSS custom properties being overwritten when dark mode is active.