Dark Mode Toggle and prefers-color-scheme

abbeyperini

Abbey Perini

Posted on April 21, 2023

Dark Mode Toggle and prefers-color-scheme

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.

  1. What is prefers-color-scheme?
  2. Emulating User Preference for Testing
  3. Detecting prefers-color-scheme with JavaScript
  4. Solution for My Toggle
  5. 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;
  }
}


Enter fullscreen mode Exit fullscreen mode

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
  }
}


Enter fullscreen mode Exit fullscreen mode

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)');


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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");


Enter fullscreen mode Exit fullscreen mode

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);
}


Enter fullscreen mode Exit fullscreen mode

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}`}>


Enter fullscreen mode Exit fullscreen mode

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');
  }


Enter fullscreen mode Exit fullscreen mode

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');


Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
abbeyperini
Abbey Perini

Posted on April 21, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related