Dark Mode Toggle and prefers-color-scheme

Dark Mode Toggle and prefers-color-scheme

ยท

4 min read

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?

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.

screenshot of chrome DevTools and abbeyperini.dev in light mode

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.

ย