When I wrote An Accessible Dark Mode Toggle in React back in 2021, @grahamthedev suggested I implement a prefers-color-scheme check in my theme setter. I finally got around to it.
- What is
prefers-color-scheme
? - Emulating User Preference for Testing
- Detecting
prefers-color-scheme
with JavaScript - Solution for My Toggle
- Refactoring
What is prefers-color-scheme
?
prefers-color-scheme
is a media feature. Media features give information about a user's device or user agent. A user agent is a program representing a user, in this case, a web browser or operating system (OS).
You're probably most familiar with media features used in media queries, like in responsive CSS.
@media (max-width: 800px) {
.container {
width: 60px;
}
}
The default for prefers-color-scheme
is "light". If the user explicitly chooses a dark mode setting on their device or in their browser, prefers-color-scheme
is set to "dark". You can use this in a media query to update your styling accordingly.
@media (prefers-color-scheme: dark) {
.theme {
color: #FFFFFF,
background-color: #000000
}
}
Emulating User Preference for Testing
In Chrome DevTools, you can emulate prefers-color-scheme
and other media features in the rendering tab.
If you prefer Firefox DevTools, it has prefers-color-scheme
buttons right in the CSS inspector.
Detecting prefers-color-scheme
with JavaScript
Unfortunately, I am not changing my theme in CSS. I'm using a combination of localStorage and swapping out class names on a component. Luckily, as always, the Web APIs are here for us.
window.matchMedia
will return a MediaQueryList
object with a boolean property, matches
. This will work with any of your typical media queries, and looks like this for prefers-color-scheme
.
window.matchMedia('(prefers-color-scheme: dark)');
Solution for My Toggle
You can check out all the code for this app in my portfolio repo.
First, I need to check if the user has been to my site and a localStorage
"theme" item has already been set. Next, I want to check if the user's preference isn't dark mode via prefers-color-scheme
. Then I want to default to setting the theme to dark mode. I also need to make sure that the toggle can update the theme after the user's initial preference is set.
My themes utility file ends up looking like this:
function setTheme(themeName, setClassName) {
localStorage.setItem('theme', themeName);
setClassName(themeName);
}
function keepTheme(setClassName) {
const theme = localStorage.getItem('theme');
if (theme) {
setTheme(theme, setClassName);
return;
}
const prefersLightTheme = window.matchMedia('(prefers-color-scheme: light)');
if (prefersLightTheme.matches) {
setTheme('theme-light', setClassName);
return;
}
setTheme('theme-dark', setClassName);
}
module.exports = {
setTheme,
keepTheme
}
My main component calls keepTheme()
in its useEffect
, and setClassName
comes from its state. I'm using useState
to default to dark mode before the localStorage
item is set.
const [className, setClassName] = useState("theme-dark");
The toggle uses setTheme()
to update the theme.
Refactoring
Previously, setTheme()
wasn't using setClassName
.
function setTheme(themeName) {
document.documentElement.className = themeName;
localStorage.setItem('theme', themeName);
}
Since I'm using React, I wanted to move away from manipulating the DOM directly. Now my main component uses a dynamic class name on its outermost element.
<div className={`App ${className}`}>
I want to refactor my component architecture at some point in the future, which may help me cut down on the number of times I'm passing setClassName
as a callback.
keepTheme()
used to be a lot of nested conditionals.
if (localStorage.getItem('theme')) {
if (localStorage.getItem('theme') === 'theme-dark') {
setTheme('theme-dark');
} else if (localStorage.getItem('theme') === 'theme-light') {
setTheme('theme-light');
}
} else {
setTheme('theme-dark');
}
My instinct is always to explicitly state the else
, so my next solution still checked too many things. I did at least start using guard clauses.
const theme = localStorage.getItem('theme');
if (theme) {
if (theme === 'theme-dark') {
setTheme('theme-dark');
}
if (theme === 'theme-light') {
setTheme('theme-light');
}
return;
}
const prefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)');
if (prefersDarkTheme.matches) {
setTheme('theme-dark');
return;
}
const prefersLightTheme = window.matchMedia('(prefers-color-scheme: light)');
if (prefersLightTheme.matches) {
setTheme('theme-light');
return;
}
setTheme('theme-dark');
At this point, I realized that if I'm already defaulting to dark mode, I don't need to check for (prefers-color-scheme: dark)
. Then I learned localStorage
items are tied to the window's origin. Since I don't need to check the value, I can just check theme
exists and then pass it to setTheme()
.
Conclusion
It was a little nostalgic to come back to this toggle. It helped me get my first developer job almost exactly two years ago. Sometimes, it can be hard to look back on code you wrote when you knew less. In this case, I was already doing the kind of updates you have to do after two years, and it was nice to see how much I've learned. It makes me want to refactor the rest of the app and excited to see what I learn in the next two years.