Dark Mode Toggle and prefers-color-scheme

The prefers-color-scheme media query is a useful feature that allows web developers to detect whether the user has a preference for a light or dark color scheme on their device. By checking for the prefers-color-scheme in your theme setter, you're making your website more accessible and user-friendly for users who prefer a certain color scheme.

Cover image for Dark Mode Toggle and prefers-color-scheme

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's always great to see how much you've grown as a developer when you revisit old code. It can be a bit intimidating to look back at code you wrote when you were less experienced, but it's also an opportunity to see how much you've learned and how far you've come. In your case, it's inspiring to see that you were already making updates and improvements to your code two years ago, and now you have even more knowledge and skills to apply to it. Refactoring the rest of the app sounds like a great idea, and I'm sure you'll continue to learn and grow as a developer in the years to come.

No comments:

Post a Comment