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).
- The basics: simple dark-mode with the
prefers-color-scheme
media query. - All in one place: defining dynamic themes with CSS custom properties.
- 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: