After publishing our tutorial on implementing dark mode at Mercury Photo Bureau, we saw an opportunity to improve the user experience by reducing dependencies and adding animation. We think the new solution is more semantic, too, so that’s a win.
The new code removes the Fontawesome dependency and replaces the <button>
element with a checkbox <input>
. It also adds some CSS animation to the toggle element and implements accessible focus
states.
You should read the original article to understand the concepts behind the code and compare them to the following changes:
The Theme Switcher Toggle
The new toggle uses an input. Here is the old HTML:
<div class="l-switcher">
<div class="balloon l-switcher__toggle" aria-label="Toggle light/dark mode" data-balloon-pos="right">
<i class="far fa-toggle-off fa-4x"></i>
<span class="screen-reader-text">Toggle light/dark mode</span>
</div>
</div>
And here is the revised HTML:
<div class="l-switcher">
<label class="l-switcher__label">
<input class="l-switcher__input h-screen-reader-text" type="checkbox" />
<span class="l-switcher__toggle"></span>
<span class="l-switcher__text balloon" aria-label="Toggle light/dark mode" data-balloon-pos="right"></span>
</label>
</div>
Styles
In addition to the color styles needed to implement dark mode, the following styles give the <input>
the appearance and function of a toggle. Thanks to Andrew Bone for the inspiration.
Note this is SASS syntax, not compiled CSS. In our implementation, 1rem
is equal to 10px
, in case you prefer to use some other unit.
// SCSS
// Visually hide the input but maintain accessibility.
.h-screen-reader-text {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.l-switcher {
// Parent container must be relative so we can use position:absolute later.
position: relative;
margin-right: 1rem;
// Since the label encloses the input, clicking or tapping it will set the
// input's state to :checked and initiate the animation.
&__label {
min-width: 15rem;
display: inline-flex;
// Vertically align the text label to the toggle element.
align-items: center;
margin: .5rem 1rem .5rem .5rem;
}
&__input {
pointer-events: none;
}
// Focus style for accessibility. Doesn't activate on click or tap.
&__input:focus-visible+.l-switcher__toggle {
outline: #141414 1px solid;
}
&__toggle {
// Indicate the element is interactive on hover.
cursor: pointer;
}
// :before is the toggle's lozenge-shaped background;
// :after is the circlular switch.
&__toggle::before,
&__toggle::after {
content: '';
margin: 0 .3rem;
// For the animation.
transition: all 100ms cubic-bezier(0.4, 0.0, 0.2, 1);
display: block;
}
&__toggle::before {
// The background.
height: 3rem;
width: 4.5rem;
border-radius: 1.5rem;
// Example color. Use your own.
background-color: #ffd700;
}
&__toggle::after {
// The round switch.
// Example color. Use your own.
background: #dc143c;
// Position the switch on top of the background.
position: absolute;
top: 50%;
left: 1rem;
// The switch's original position.
transform: translate(0, -50%);
height: 2rem;
width: 2rem;
// Make it circular.
border-radius: 50%;
}
// Move the round switch when the input is checked.
& [type=checkbox]:checked+.l-switcher__toggle::after {
transform: translate(calc(3.5rem - 100%), -50%);
}
}
JavaScript
Here’s the updated JavaScript. Safari 14 supports addEventListener()
for MediaQueryList
, so you may wish to omit the deprecated addListener()
block.
The main differences from the original are that we no longer check for user interaction to change the Fontawesome icon, and we changed our “click” target from .l-switcher
to .l-switcher__input
, i.e., $('.l-switcher__input').on('click', function () {
:
"use strict";
jQuery(document).ready(function ($) {
/*
* Theme mode switcher lite:dark.
*/
function themeSwitcher() {
const match = window.matchMedia('( prefers-color-scheme: dark )');
localStorage.setItem('darkMode', '');
// Does OS support Dark Mode?
if (window.matchMedia) {
// OS Dark Mode is preferred.
if (match.matches) {
localStorage.setItem('darkMode', 'isDark');
$('body')
.removeClass('theme-lite')
.addClass('theme-dark');
$('.l-switcher__text').html('DODGE ME!');
// Use default Light scheme.
} else {
localStorage.setItem('darkMode', 'islite');
$('body')
.removeClass('theme-dark')
.addClass('theme-lite');
$('.l-switcher__text').html('BURN ME!');
}
// Good browsers support addEventListener.
try {
// Watch for OS color mode change.
match.addEventListener('change', (event) => {
// Dark Mode enabled and is not overriden by user.
if ((event.matches) && (localStorage.getItem('darkModePref') !== 'prefsLite')) {
localStorage.setItem('darkMode', 'isDark');
$('body')
.removeClass('theme-lite')
.addClass('theme-dark');
$('.l-switcher__text')
.html('DODGE ME!');
// Light Mode enabled and is not overriden by user.
} else if (localStorage.getItem('darkModePref') !== 'prefsDark') {
localStorage.setItem('darkMode', 'isLite');
$('body')
.removeClass('theme-dark')
.addClass('theme-lite');
$('.l-switcher__text')
.html('BURN ME!');
} else {
console.error('Theme switcher could not determine OS dark/light mode');
}
});
} catch (e1) {
try {
// Watch for OS color mode change. Using deprecated `addListener()`
// for Safari < 14, otherwise we'll get an error.
match.addListener((event) => {
// Dark Mode enabled.
if ((event.matches)) {
localStorage.setItem('darkMode', 'isDark');
$('body')
.removeClass('theme-lite')
.addClass('theme-dark');
$('.l-switcher__text')
.html('DODGE ME!');
// Light Mode enabled.
} else {
localStorage.setItem('darkMode', 'isLite');
$('body')
.removeClass('theme-dark')
.addClass('theme-lite');
$('.l-switcher__text')
.html('BURN ME!');
}
});
} catch (e2) {
console.error(e2);
}
}
// Dark Mode not supported. Use light theme unless overriden by user.
} else {
localStorage.setItem('darkMode', 'isLite');
$('body')
.removeClass('theme-dark')
.addClass('theme-lite');
$('.l-switcher__text')
.html('BURN ME!');
}
$('.l-switcher__input').on('click', function () {
// OS = Light Mode or default. It doesn't matter if Dark Mode is supported
// because we set darkMode => isLite already.
if (localStorage.getItem('darkMode') === 'isLite') {
// User pref was Light, so change to Dark.
if (localStorage.getItem('darkModePref') !== 'prefsDark') {
localStorage.setItem('darkModePref', 'prefsDark');
$('body')
.removeClass('theme-lite')
.addClass('theme-dark');
$('.l-switcher__text')
.html('DODGE ME!');
// User pref was Dark, so change to Light.
} else {
localStorage.setItem('darkModePref', 'prefsLite');
$('body')
.removeClass('theme-dark')
.addClass('theme-lite');
$('.l-switcher__text')
.html('BURN ME!');
}
// OS = Dark Mode supported and enabled.
} else {
// User pref was Dark, so change to Light.
if (localStorage.getItem('darkModePref') !== 'prefsLite') {
localStorage.setItem('darkModePref', 'prefsLite');
$('body')
.removeClass('theme-dark')
.addClass('theme-lite');
$('.l-switcher__text')
.html('BURN ME!');
// User pref was Light, so change to Dark.
} else {
localStorage.setItem('darkModePref', 'prefsDark');
$('body')
.removeClass('theme-lite')
.addClass('theme-dark');
$('.l-switcher__text')
.html('DODGE ME!');
}
}
});
// Now check user pref and update the body class.
function updateFunction() {
if (localStorage.getItem('darkModePref') === 'prefsLite') {
$('body')
.removeClass('theme-dark')
.addClass('theme-lite');
$('.l-switcher__text')
.html('BURN ME!');
} else if (localStorage.getItem('darkModePref') === 'prefsDark') {
$('body')
.removeClass('theme-lite')
.addClass('theme-dark');
$('.l-switcher__text')
.html('DODGE ME!');
} else {
console.info('localStorage item "darkModePref" is null.');
}
}
updateFunction();
}
themeSwitcher();
}); // End jQuery
Wrapping Up
This is just an overview of the changes from the original implementation, and it omits most of the style examples related to the site color scheme. You should read both articles to get a handle on how the theme switcher works. If you liked this article, found a mistake, or have any suggestions for improving it, please let me know!
The featured image is by user “Devanath” on Pixnio.
Leave a Reply