Dark mode: hard mode

Implementing dark mode with CSS custom properties, SASS, and JavaScript

Dark mode has been around in operating systems and web browsers for a while now, but it wasn't something I'd ever really cared about. But that all changed when my brother implemented a dark-mode stylesheet for his (seldom updated but excellent) blog, edthecoder.dev. In a true spirit of "anything you can do, I can do better", the sibling rivalry kicked in and I knew I had to implement dark mode on my own site. If a "mere" backend dev could implement a solid dark mode, surely a "frontend expert" such as myself should be able to do the same (but better).

  1. The basics: simple dark-mode with the prefers-color-scheme media query.
  2. All in one place: defining dynamic themes with CSS custom properties.
  3. Live switching: toggling dark-mode without having to change a system-wide preference.

The basics

At its simplest, "dark mode" can be applied to your stylesheet by using the prefers-color-scheme media query. This behaves in much the same way as more familiar size-related queries (for example @media (min-width: 960px) {}), and will only apply the styles within it if the browser detects that the system has the prefers-color-scheme: dark flag set.

.thing {
    /* "normal" colour rules */
    color: black;
    background: white;

    @media (prefers-color-scheme: dark) {
        /* These colour rules will only be
        applied if the site is loaded on a
        machine with dark-mode enabled */
        color: white;
        background: black;
    }
}

All in one place

I've previously been declaring the colours in my stylesheet using scss variable (for example, $black: #4d4d4d). This would be fine if I was happy adding @media rules to handle dark-mode throughout my stylesheet, but that comes with a few issues:

  • The dark-mode specific rules would be spread all over the place, introducing unnecessary complexity and creating a maintenance headache.
  • Sass variables are not dynamic (a.k.a. they are defined once, at compilation time), and therefore wouldn't be able to adjust on-the-fly depending on the light/dark setting of the client.

What I need are colour variables that can change value depending on their context. What I need are CSS Custom Properties.

CSS Custom Properties

Custom Properties are native CSS variables, and they are supported in all major browsers (RIP in peace, IE). Unlike Sass variables, they are evaluate by the client at runtime. This means that our stylesheet can adapt to whatever mode the client is using (light or dark), and we could even change that setting dynamically with JavaScript.

Note: Unlike variables in scss, custom properties need to be declared inside a wrapper. Common practice is to use the :root pseudo class to ensure the custom properties are accessible in the entire cascade (:root represents the element and is identical to the selector html, except that its specificity is higher).

/* a static Sass variable */
$primary: red;

/* a dynamic custom property */
:root {
    --primary: red;
}

.unchanging_thing {
    /* This value is fixed when the stylesheet is compiled */
    color: $primary;
}

.changing_thing {
    /* This value will change on-the-fly if --primary is changed */
    color: var(--primary);
}

For a long time now, I've declared all colour variables in a single scss file (_settings.colours.scss, in case you were wondering) which makes the job of maintaining a unified "theme" across a complex site a relatively simple task. Having to spread prefers-color-scheme queries throughout the various parts of the stylesheet messes this up big time.

Using custom properties allows us to abstract the theme-specific parts of the stylesheet away from specific elements. Crucially, it allows us to add a single prefers-color-scheme query into the existing colours.scss partial. Nice and tidy!

/* Default colours */
:root {
    --primary: red;
    --secondary: blue;
    /* etc... */

    @media (prefers-color-scheme: dark) {
        /* Dark-theme colour alternatives */
        --primary: maroon;
        --secondary: navy;
        /* etc... */
    }
}

Gotchas when using custom properties and Sass

Something to watch out for when using custom properties inside Sass is that things get weird when you try to use them inside functions. Sass is evaluated when the file is complied, so things like darken($colourVar, 10%) are set in stone as soon as you hit save (and therefore not dynamic when the file is loaded by a browser). Using a custom property in this instance will result in an error, and the scss will not compile.

If you're locked-in to (pseudo-)dynamically adjusting colour values, then CSS' native filter rule might be an option for you. Because filter adjusts the entire element, I find it much simpler to manually set the colour value I want. So rather than using $red: #ff0000; and $red--dark: darken(red, 20%);, we would need to write --red: #ff0000; and --red--dark: #990000;

Another thing to watch out for is how specific you need to be when formatting colours. With Sass variables, I've grown accustomed to being able to declare a colour as a hex value and have it "just work" wherever I want to use it. I could declare a variable as #ff0000 and use that variable inside an rgba() function to apply opacity: rgba($red, 0.3). In that instance, rather than having to convert my hex colour into the RGB equivalent (255, 0, 0), Sass would do the conversion work for me.

With native CSS custom properties, we don't have this convenience.

/* Works in Sass */
$red: #ff0000;
$red--translucent: rgba($red, 0.3);

/* Does not work in Sass (or anywhere else) */
--red: #ff0000;
--red--translucent: rgba(--red, 0.3);

/* Works everywhere */
--red: #ff0000;
--red--translucent: rgba(255, 0, 0, 0.3);

This meant that I had to do a lot of hex-to-rgb conversion when converting my stylesheet to use custom properties. Turns out I was a heavy user of both RGBA colours and declared all my colours as hex values. Oops.


Live switching

By doing all that we've done so far, we've successfully implemented dark mode! After a trivial long and hard refactor I've now reached feature-parity with my brother's site. But the goal here was to surpass Ed's efforts, and the best way I can think of to achieve that is really lean in to the dynamic aspect of the custom properties I've taken such pains to add.

What we've done so far is to switch colour-theme based on the prefers-color-scheme value, but that's not the only thing we can use to toggle between themes. With our custom properties in place, I could add as many "themes" as I want. For now I'm happy with "light mode" and "dark mode", but to prove the concept it would be great to be able to toggle between the modes on-the-fly. Currently, unless a visitor to the site already has dark-mode enabled on their system, there's no way for them to know that the dark version of the site even exists.

Allowing users to dynamically toggle between themes has a few requirements:

  • We'll need to detach the theme-switching logic from the prefers-color-scheme state (but still respect that setting if it exists).
  • We'll need a UI element to activate the toggling, with display states for both themes.
  • We'll need to persist the user's setting between page loads (requiring them to re-set the value on every page load would be really obnoxious).

Here comes the JavaScript!

The first step is easy. We can move the dark-mode custom properties out of the @media query, and use a data attribute instead:

:root {
    --primary: #00b7c6;
    /* Normal colours... */
}

:root[data-theme="dark"] {
    --primary: #ff851b;
    /* Dark-mode colours... */
}

The next step is to create a JavaScript function to handle changing that data attribute. This setDarkMode() function will accept a boolean parameter that decides if we're applying light or dark mode. We'll also set a localStorage value so that the page remembers which mode the user has chosen.

const setDarkMode = (active = false) => {
    const wrapper = document.querySelector(":root");
    if (active) {
        wrapper.setAttribute("data-theme", "dark");
        localStorage.setItem("theme", "dark");
    } else {
        wrapper.setAttribute("data-theme", "light");
        localStorage.setItem("theme", "light");
    }
};

With that function in place, we then need to run it when the page loads. This is where we need to detect the system preference (the JS equivalent of the CSS prefers-color-scheme value). We can do this with the browser's matchMedia() method - which returns the value of a given media query. So if we retrieve this value when the page loads, we can call our setDarkMode() function with the correct boolean. As an added bonus, we can attach an event listener to the query which will fire whenever the system setting changes (meaning that if someone switches dark mode on or off while the page is open, the change will be applied instantly).

const query = window.matchMedia("(prefers-color-scheme: dark)");
setDarkMode(query.matches);
query.addListener(e => setDarkMode(e.matches));

This is made a little more complicated when we account for the setting from localStorage, which we want to be able to override the system preference (this is the user's explicit choice, after all).

const query = window.matchMedia("(prefers-color-scheme: dark)");
const themePreference = localStorage.getItem("theme");

let active = query.matches;
if (themePreference === "dark") {
    active = true;
}
if (themePreference === "light") {
    active = false;
}

setDarkMode(active);
query.addListener(e => setDarkMode(e.matches));

But even with this plumbing set up and ready, we're still missing a key part of the puzzle: the ability for user's to actually set their preference on the page itself. For that, we'll need a <button> in our HTML and an event listener to handle to toggling of the theme.

const toggleDarkMode = () => {
    const theme = document.querySelector(":root").getAttribute("data-theme");
    // If the current theme is "light", we want to activate the dark theme
    setDarkMode(theme === "light");
};

// ".js__dark-mode-toggle" is the unique class we've added to the <button>
const toggleButton = document.querySelector(".js__dark-mode-toggle");
toggleButton.addEventListener("click", toggleDarkMode);

Putting it all together

The final piece of the puzzle is to add some CSS sparkle to the button element (I ended up settling for a cheesy single-div sun/moon combo), and then we've got all the pieces we need! You can see all this code in action live on this very site; click the icon in the top right of the screen to toggle the light/dark modes. You can also see the full code (and mess about with it to your heart's content) in the CodePen demo embedded below:

See the Pen Dark Mode Toggle by Tom Hazledine (@tomhazledine) on CodePen.



Related posts

If you enjoyed this article, RoboTom 2000™️ (an LLM-powered bot) thinks you might be interested in these related posts:

So long, and thanks for all the Sass

After 10 happy years, I’m saying goodbye to SCSS

Similarity score: 66% match . RoboTom says:

Getting to grips with SVG markup

SVGs are complex, for sure, but that very complexity gives them their power. And we don't need to know the intricacies of the co-ordinate system to harness that power.

Similarity score: 63% match . RoboTom says:

Newer post:

Building a delay effect with the Web Audio API

Published on

Older post:

Quirks mode

Published on


Signup to my newsletter

Join the dozens (dozens!) of people who get my writing delivered directly to their inbox. You'll also hear news about my miscellaneous other projects, some of which never get mentioned on this site.