Introduction
I recently read Dave Rupert’s article “Sass vars, CSS vars, and semantic theme vars”, in which the author makes that case that just because we have variables in CSS now doesn’t mean SASS variables are dead. In the article, Rupert describes how he used both types of variables to implement dark mode on a side project.
This inspired me to implement a dark mode theme for my photo blog, using some of Rupert’s techniques. I got it working pretty well, but then I got to thinking, What if users could toggle their preference, in case they want to override the operating system (OS) display setting?
That led me down a rabbit-hole of JavaScript, browser storage, discovering that my workflow didn’t lint for ES6, and four days barely getting up from my desk while I figured out how to make the darned thing work. It was a whole deal.
Dependencies
The toggle switch is the Fontawesome toggle icon fa-toggle-off
, which we replace with fa-toggle-on
when the user clicks it. You’ll need to register and enqueue the Fontawesome files. If you’re not sure how to do that, read the Dependencies section of our previous article.
Our HTML uses the .balloon
class and the data-balloon-pos
attribute with Balloon CSS to show a tooltip on hover or focus. You don’t have to use it, but you should provide some labeling to indicate the purpose of the switcher.
Styles and Variables
In your stylesheet, map all of your SASS color variables to CSS variables. I needed to keep the SASS color variables in my stylesheet because I have some backgrounds that remain dark regardless of whether the theme is light or dark. Using those variables allows me to keep those parts of the theme immutable when dark mode is toggled.
Here’s a simplified example:
// $Colors
$linen:rgb(255, 251, 245); //
$linen-lite:rgb(255, 253, 250);
$black: rgb(7,7,7); //
$rich-black:rgb(38, 38, 38); //
// $Button Hover Styles
$linen-hover:rgb(255, 255, 255); //
$linen-active:rgb(255, 237, 209); //
$rich-black-hover:rgb(77,77,77); //
$rich-black-active:rgb(115,115,115); //
// CSS Variables
html {
--linen: #{$linen};
--linen-lite: #{$linen-lite};
--black: #{$black};
--rich-black: #{$rich-black};
// Button Hover Styles.
--linen-hover: #{$linen-hover};
--linen-active: #{$linen-active};
--rich-black-hover: #{$rich-black-hover};
--rich-black-active: #{$rich-black-active};
}
We’ll use the media query prefers-color-scheme: dark
to determine the OS setting. Also, because we’ll be using JavaScript to determine when the user wants to override the theme mode, we need a body class to hook into when they make that choice. Let’s use .theme-lite
and .theme-dark
.
body { // Theme variables. &.theme-lite { // Colors to use if the user chooses a light theme. } &.theme-dark { // Colors to use if the user chooses a dark theme. } &:not(.theme-lite) { @media (prefers-color-scheme: dark) { // Colors to use if the OS is set to dark mode and the user hasn't // overridden the dark theme. } } }
And then we just fill them in like this:
body { // Theme variables -- any rules placed outside the following blocks will be // unaffected by dark mode or user preferences. The following rule is // therefore global: --global-variable: var(--some-global-variable); &.theme-lite { --text: var(--rich-black); --text-alt: var(--black); --bg: var(--linen); --bg-alt: var(--linen-lite); --btn-bg-hover: var(--linen-hover); --btn-bg-active: var(--linen-active); --btn-bg-hover-alt: var(--rich-black-hover); --btn-bg-active-alt: var(--rich-black-active); } &.theme-dark { --text: var(--linen); --text-alt: var(--linen-lite); --bg: var(--rich-black); --bg-alt: var(--black); --btn-bg-hover: var(--rich-black-hover); --btn-bg-active: var(--rich-black-active); --btn-bg-hover-alt: var(--linen-black-hover); --btn-bg-active-alt: var(--linen-black-active); } &:not(.theme-lite) { // Same colors as &.theme-dark. @media (prefers-color-scheme: dark) { --text: var(--linen); --text-alt: var(--linen-lite); --bg: var(--rich-black); --bg-alt: var(--black); --btn-bg-hover: var(--rich-black-hover); --btn-bg-active: var(--rich-black-active); --btn-bg-hover-alt: var(--linen-black-hover); --btn-bg-active-alt: var(--linen-black-active); } } }
Let’s say your current theme has mostly dark text on a light background — in other words, it’s a light theme, and would be the default theme for an OS in light mode. Given the following SASS,
body {
color: $rich-black;
background-color: $linen;
& main {
color: $black;
background-color: $linen-lite;
}
}
.btn {
color: $rich-black;
background-color $linen;
&:hover {
background-color: $linen-hover;
}
&:active {
background-color: $linen-active;
}
&-alt {
color: $linen;
background-color: $rich-black;
&:hover {
background-color: $rich-black-hover;
}
&:active {
background-color: $rich-black-active;
}
}
}
you would modify your styles thusly:
body {
color: var(--text);
background-color: var(--bg);
& main {
color: var(--text-alt);
background-color: var(--bg-alt);
}
}
.btn {
color: var(--text);
background-color var(--bg);
&:hover {
background-color: var(--btn-bg-hover);
}
&:active {
background-color: var(--btn-bg-active);
}
&-alt {
color: var(--bg);
background-color: var(--text);
&:hover {
background-color: var(--btn-bg-hover-alt);
}
&:active {
background-color: var(--btn-bg-active-alt);
}
}
}
Adding the Theme Switcher Button
The Mercury Photo Bureau theme is a bespoke Genesis theme, so we added our theme switch by filtering the Genesis action hook genesis_before_content
. If you don’t use the Genesis framework on your site, you can filter one of the standard WordPress hooks.
/*
* Theme mode switcher.
*/
function my_function_theme_switcher() {
// Don't show the switcher on the front page.
if ( ! is_front_page() ) {
// Render the toggle element here.
}
};
add_action( 'genesis_before_content', 'my_function_theme_switcher' );
As noted in the dependencies, we’ll display the toggle via a couple of Fontawesome icons and label it with a tooltip provided by balloon.css
:
<div class="l-switcher">
<div class="balloon l-switcher__toggle" aria-label="Toggle light/dark mode" data-balloon-pos="right">
// FontAwesome toggle icon.
<i class="far fa-toggle-off fa-4x"></i>
// Visually hidden but accessible helper text.
<span class="screen-reader-text">Toggle light/dark mode</span>
</div>
</div>
Putting that together, we get the following. Drop it into your theme’s functions.php
or use it in your custom plugin:
/*
* Theme mode switcher.
*/
function my_function_theme_switcher() {
// Don't show the switcher on the front page.
if ( ! is_front_page() ) {
echo'
<div class="l-switcher">
<div class="balloon l-switcher__toggle" aria-label="Toggle light/dark mode" data-balloon-pos="right">
// FontAwesome toggle icon.
<i class="far fa-toggle-off fa-4x"></i>
// Visually hidden but accessible helper text.
<span class="screen-reader-text">Toggle light/dark mode</span>
</div>
</div>';
}
};
add_action( 'genesis_before_content', 'my_function_theme_switcher' );
Code Logic
Next, we’ll write the code to check whether dark mode is preferred in the OS and to allow the user to override the theme mode. The logic will work like this:
- Set a localStorage key
darkMode
with an empty value - Check whether dark mode is suported at the OS level
- If it is, check whether light or dark mode is selected, then update the localStorage key to a value of either
isLite
orisDark
; also set or update thebody
class - Watch for the OS display mode to change
- If it changes, update the value of the localStorage item and the
body
class - If dark mode isn’t suported at the OS level, set
darkMode:isLite
in localStorage - Watch for the user to click the theme mode toggle
- On click if
darkMode:isLite
and the user hasn’t already overridden the light theme, set a localStorage keydarkModePref
toprefsDark
, remove thebody
class.theme-lite
and add thebody
class.theme-dark
- Otherwise set a localStorage key
darkModePref
toprefsLite
, remove thebody
class.theme-dark
and add thebody
class.theme-lite
- Also on click, toggle the
data-icon
attribute on the Fontawesome SVG
The Good Stuff: the JavaScript
Create a file .theme-switcher.js
and update your functions.php
or other appropriate theme file to register and enqueue it. At the top of the file, paste the following code.
"use strict" // Modern JavaScript only!
jQuery(document).ready(function ($) { // Make jQuery play nice with WordPress
/**
* Theme mode switcher lite:dark
*/
function themeSwitcher() {
localStorage.setItem('darkMode', '');
if (window.matchMedia) { // Does OS support Dark Mode?
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
// OS Dark Mode is preferred.
localStorage.setItem('darkMode', 'isDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
} else { // Use default Light scheme.
localStorage.setItem('darkMode', 'isLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
}
} else { // Dark Mode not supported. Use light theme unless overriden by user.
localStorage.setItem('darkMode', 'isLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
}
}
themeSwitcher();
}); // End jQuery
The outer block jQuery(document).ready(function ($) { … }
delays code execution until the DOM has loaded and also allows us to use $
inside our function without conflicting with WordPress or other JavaScript libraries. If you load the script in your footer, and you should, use an anonymous function (function($) { … })( jQuery );
instead.
The themeSwitcher()
function enacts the logic in steps 1, 2, 3, and 6:
- Set a localStorage key
darkMode
with an empty value:
localStorage.setItem('darkMode', '');
- Check whether dark mode is suported at the OS level:
if (window.matchMedia) { … }
- If it is, check whether light or dark mode is selected, then update the localStorage key to a value of either
isLite
orisDark
and update thebody
class - If dark mode isn’t suported at the OS level, set
darkMode:isLite
in localStorage
Now let’s watch for the OS preference to change. Paste this code before the themeSwitcher()
function’s closing curly brace:
const match = window.matchMedia('( prefers-color-scheme: dark )');
try {
// Good browsers support addEventListener.
match.addEventListener('change', (event) => {
// Watch for OS color mode change.
if ((event.matches)) { // Dark Mode enabled.
localStorage.setItem('darkMode', 'isDark')
$('body').removeClass('theme-lite').addClass('theme-dark')
} else { // Light Mode enabled.
localStorage.setItem('darkMode', 'isLite')
$('body').removeClass('theme-dark').addClass('theme-lite')
}
});
} catch (e1) {
try {
match.addListener((event) => {
// Watch for OS color mode change. Using deprecated
// `addListener()` for Safari, otherwise we'll get an error.
if ((event.matches)) { // Dark Mode enabled.
localStorage.setItem('darkMode', 'isDark')
$('body').removeClass('theme-lite').addClass('theme-dark')
} else { // Light Mode enabled.
localStorage.setItem('darkMode', 'isLite')
$('body').removeClass('theme-dark').addClass('theme-lite')
}
});
} catch (e2) {
console.error(e2);
}
}
Let’s break that down. First, we declare a constant match
:
const match = window.matchMedia('( prefers-color-scheme: dark )');
The value of match
will evaluate to either true
or false
based on the OS dark mode preference. const
is one of the new ES6 features. The value of a constant can’t be changed through reassignment, nor it can’t be redeclared. Use it when you need a variable with a static value.
Next we employ addEventListener()
to watch the value of match
. Because some browsers, notably Safari <14, will throw an error when presented with window.matchMedia(‘( prefers-color-scheme: dark )’).addEventListener
, we start with a try
block:
try {
match.addEventListener('change', (event) => { … }
}
If the value of match
changes, we update our localStorage value as well as the body
classes:
if ((event.matches)) {
localStorage.setItem('darkMode', 'isDark')
$('body').removeClass('theme-lite').addClass('theme-dark')
} else {
localStorage.setItem('darkMode', 'isLite')
$('body').removeClass('theme-dark').addClass('theme-lite')
}
If the code in the block fails (*cough* Safari), we fall back to the deprecated addListener()
, running the same logic inside:
match.addListener((event) => { … }
More JavaScript: Respecting User Preferences
Now we watch for the user to override the automatic theme switching. When the user clicks the toggle, we need to determine what the current theme mode is and toggle it to what it isn’t. Locate this code block:
} else { // Dark Mode not supported. Use light theme unless overriden by user.
localStorage.setItem('darkMode', 'isLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
};
And paste this code after it:
$('.l-switcher').on('click', function () {
if (localStorage.getItem('darkMode') === 'isLite') {
/* OS = Light Mode or default. It doesn't matter if Dark Mode is supported
* because we set darkMode => isLite already.
*/
if (localStorage.getItem('darkModePref') !== 'prefsDark') { // User pref was Light, so change to Dark.
localStorage.setItem('darkModePref', 'prefsDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
} else { // User pref was Dark, so change to Light.
localStorage.setItem('darkModePref', 'prefsLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
}
} else { // OS = Dark Mode supported and enabled.
if (localStorage.getItem('darkModePref') !== 'prefsLite') { // User pref was Dark, so change to Light.
localStorage.setItem('darkModePref', 'prefsLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
} else { // User pref was Light, so change to Dark.
localStorage.setItem('darkModePref', 'prefsDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
}
};
When the user clicks the toggle with the class .l-switcher
, we check the values of the keys darkMode
and darkModePref
:
$('.l-switcher').on('click', function () {
if (localStorage.getItem('darkMode') === 'isLite') {
if (localStorage.getItem('darkModePref') !== 'prefsDark') { … }
}
}
If darkMode
returns isLite
(dark mode is not enabled) and darkModePref
returns either prefsLite
or null
, we update them to isDark
and prefsDark
, respectively. We also toggle the body
class from .theme-lite
to .theme-dark
:
localStorage.setItem('darkModePref', 'prefsDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
Conversely, if darkModePref
returns prefsDark
, we update it to prefsLite
, remove the body
class .theme-dark
and add .theme-dark
:
localStorage.setItem('darkModePref', 'prefsLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
The final block runs similar logic if OS dark mode is enabled:
if (localStorage.getItem('darkModePref') !== 'prefsLite') {
localStorage.setItem('darkModePref', 'prefsLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
} else {
localStorage.setItem('darkModePref', 'prefsDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
}
Changing the Icon
We also want to change the icon when the user clicks our toggle. The HTML for our icon looks like this:
<i class="far fa-toggle-off fa-4x"></i>
But if you look at the generated HTML, you’ll see this instead:
<svg class="svg-inline--fa fa-toggle-off fa-w-18 fa-4x" aria-hidden="true" focusable="false" data-prefix="far" data-icon="toggle-off" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" data-fa-i2svg> … </svg>
<!-- <i class="far fa-toggle-off fa-4x"></i> -->
If you try to get the icon by the .fa-toggle-off
class like this, it will fail:
$( ".fa-toggle-off" ).removeClass('fa-toggle-off').addClass('fa-toggle-on')
That’s because Fontawsome replaced our <i>
with a new <svg>
element. Since <i>
is replaced, any bindings to it are lost.
So how do we fix this? Look at the <svg>
element again. See the custom attribute data-fa-i2svg
? We can use find
to look for that and manipulate the SVG to contain the necessary attributes to update the icon:
$('.l-switcher').on('click', function () {
if (localStorage.getItem('darkMode') === 'isLite') { … }
/* Change the FontAwesome icon on click */
$(this).find('[data-fa-i2svg]');
if ($(this).find('[data-fa-i2svg]').attr('data-icon') == 'toggle-off') {
$(this).find('[data-fa-i2svg]')
.removeClass('fa-toggle-off')
.addClass('fa-toggle-on')
.attr('data-icon', 'toggle-on');
} else {
$(this).find('[data-fa-i2svg]')
.removeClass('fa-toggle-on')
.addClass('fa-toggle-off')
.attr('data-icon', 'toggle-off');
}
});
First find any element inside <div class="l-switcher>
with the attribute data-fa-i2svg
:
$(this).find('[data-fa-i2svg]');
Now match any element with an attribute data-icon
that has a value of toggle-off
:
if ($(this).find('[data-fa-i2svg]').attr('data-icon') == 'toggle-off') { … }
If we have a match, replace the icon classes and attributes with those for the toggle “on” icon:
$(this).find('[data-fa-i2svg]')
.removeClass('fa-toggle-off')
.addClass('fa-toggle-on')
.attr('data-icon', 'toggle-on');
Otherwise replace the icon classes and attributes with those for the toggle “off” icon:
$(this).find('[data-fa-i2svg]')
.removeClass('fa-toggle-on')
.addClass('fa-toggle-off')
.attr('data-icon', 'toggle-off');
Updating the Theme
We still need to update the theme based on the user’s choice. Paste this code right before the closing brace of the themeSwitcher()
function:
/* Now check user pref and update the body class */
if (localStorage.getItem('darkModePref') === 'prefsLite') {
$('body').removeClass('theme-dark').addClass('theme-lite')
} else if (localStorage.getItem('darkModePref') === 'prefsDark') {
$('body').removeClass('theme-lite').addClass('theme-dark')
} else {
console.info('localStorage item "darkModePref" is null.');
}
If you’ve been following along, you already know what this does: checks the user preference and updates it when when the user clicks the toggle button. If it’s not already set, we log an informational message to the console.
Putting the JavaScript All Together
Your finished code should look like this. Inline comments removed for readability:
"use strict"
jQuery(document).ready(function ($) {
/**
* Theme mode switcher lite:dark
*/
function themeSwitcher() {
localStorage.setItem('darkMode', '');
if (window.matchMedia) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
localStorage.setItem('darkMode', 'isDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
} else {
localStorage.setItem('darkMode', 'isLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
}
const match = window.matchMedia('( prefers-color-scheme: dark )');
try {
match.addEventListener('change', (event) => {
if ((event.matches)) {
localStorage.setItem('darkMode', 'isDark')
$('body').removeClass('theme-lite').addClass('theme-dark')
} else {
localStorage.setItem('darkMode', 'isLite')
$('body').removeClass('theme-dark').addClass('theme-lite')
}
});
} catch (e1) {
try {
match.addListener((event) => {
if ((event.matches)) {
localStorage.setItem('darkMode', 'isDark')
$('body').removeClass('theme-lite').addClass('theme-dark')
} else {
localStorage.setItem('darkMode', 'isLite')
$('body').removeClass('theme-dark').addClass('theme-lite')
}
});
} catch (e2) {
console.error(e2);
}
}
} else {
localStorage.setItem('darkMode', 'isLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
}
$('.l-switcher').on('click', function () {
if (localStorage.getItem('darkMode') === 'isLite') {
if (localStorage.getItem('darkModePref') !== 'prefsDark') {
localStorage.setItem('darkModePref', 'prefsDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
} else {
localStorage.setItem('darkModePref', 'prefsLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
}
} else {
if (localStorage.getItem('darkModePref') !== 'prefsLite') {
localStorage.setItem('darkModePref', 'prefsLite');
$('body').removeClass('theme-dark').addClass('theme-lite')
} else {
localStorage.setItem('darkModePref', 'prefsDark');
$('body').removeClass('theme-lite').addClass('theme-dark')
}
};
/* Change the FontAwesome icon on click */
$(this).find('[data-fa-i2svg]');
if ($(this).find('[data-fa-i2svg]').attr('data-icon') == 'toggle-off') {
$(this).find('[data-fa-i2svg]')
.removeClass('fa-toggle-off')
.addClass('fa-toggle-on')
.attr('data-icon', 'toggle-on');
} else {
$(this).find('[data-fa-i2svg]')
.removeClass('fa-toggle-on')
.addClass('fa-toggle-off')
.attr('data-icon', 'toggle-off');
}
});
/* Now check user pref and update the body class */
if (localStorage.getItem('darkModePref') === 'prefsLite') {
$('body').removeClass('theme-dark').addClass('theme-lite')
} else if (localStorage.getItem('darkModePref') === 'prefsDark') {
$('body').removeClass('theme-lite').addClass('theme-dark')
} else {
console.info('localStorage item "darkModePref" is null.');
}
}
themeSwitcher();
});
Bonus: Hiding Things with a Utility Class
Astute readers will have noted the .screen-reader-text
class in our switcher’s HTML. Here are some styles you can use with it:
.screen-reader-text {
border: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
word-wrap: normal !important;
}
.screen-reader-text:focus {
background-color: #eee;
clip: auto !important;
clip-path: none;
color: #444;
display: block;
font-size: 1em;
height: auto;
left: 5px;
line-height: normal;
padding: 15px 23px 14px;
text-decoration: none;
top: 5px;
width: auto;
z-index: 100000; /* Above WP toolbar. */
}
Conclusion
As previously stated, it took me four days to write this code and make it work. I made many mistakes along the way, and I learned a few things too — such as, my text-editor’s compiler didn’t support ES6’s let
or const
, which was why my minified JavaScript was all effed up. Hopefully I’ve saved you from some of the same mistakes, and you learned something too.
If you liked this article, leave a comment or drop me a line. I’d love to hear from you.
Leave a Reply