How to Make the Perfect Dark Mode Switcher

Allow your website to support a user's preferences and easily be switched

·

6 min read

Websites should match users' system settings for the best experience. One important setting is the system theme, which can be dark or light. Here's how to do that in the best way.

image.png

1. The Basics: prefers-color-scheme Media Query

Browsers now support media queries to allow you to match the operating system theme using only CSS like so:

/* Adjust the theme to match the operating system */
@media (prefers-color-scheme: dark) {
  body {
      background-color: black;
      color: white;
  }

  a {
    color: skyblue;
  }
}

This CSS will use the default colors unless the user's OS is in a dark theme, in which case the body will have a black background with white text and light blue links.

Great! But what if the user wants to switch to a theme different than their OS setting?

2. Manual Switching with a 'dark' Class

We can add a "dark" class to the html tag so that any descendants can inherit dark styles in our CSS instead of using a media query. This makes it quite easy to toggle back and forth using JavaScript.

Let's make a basic webpage to demonstrate.

<html>
  <head>
    <style>
      .dark body {
        background-color: black;
        color: white;
      }      
    </style>
  </head>

  <body>
    <button onclick="toggleTheme()">Switch Theme</button>
    <h1>Hello World!</h1>
    <script>
      function toggleTheme() {
        let htmlClassList = document.querySelector('html').classList

        if (htmlClassList.contains('dark')) {
          htmlClassList.remove('dark')
        }
        else {
          htmlClassList.add('dark')
        }
      }
    </script>
  </body>
</html>

Voilà — you can now switch the theme! We have a toggleTheme() function that gets the classList of the html element, adding or removing the dark class from it, depending on whether it is present.

3. Detecting The Client's Theme While Allowing Switching

But all is not quite right... you see, when a user with a dark theme loads this page, it will load with default colors — usually light theme. A dark mode user will be blinded with a white screen.

Hmm... we didn't have that problem using the simple @media query approach. So what do we do?

We can add a little more JS to preemptively add the class in the html element depending on the user's theme. We use the matchMedia API to determine what the current theme is. We pass in prefers-color-scheme: dark, and get back a MediaQueryList object that contains a matches property. If matches is true, the user has a dark theme, and we should add the dark class.

if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
  document.querySelector('html').classList.add('dark')
}

It's best to add this JS code into the head of the document — if you don't, there may be a "flash of unstyled content" when the page loads. Since we are only modifying the html element at the very beginning, we don't have to wait for it to load after the JS in the head element.

4. Saving And Restoring The User's Chosen Theme

So where are we? We now allow the user to switch their theme while also respecting the user's default theme. But what if the user switches their theme and then reloads the page later?

Well, the theme will revert back to the system default. That would get annoying quickly for a frequent visitor who changed the theme.

Let's add more smarts by saving the user's preference into the browser's localStorage.

First let's revisit the toggleTheme() function from earlier and have it set a theme value in localStorage when the theme is switched.

function toggleTheme() {
  let htmlClassList = document.querySelector('html').classList
  if (htmlClassList.contains('dark')) {
    htmlClassList.remove('dark')
    localStorage.theme = 'light'
  }
  else {
    htmlClassList.add('dark')
    localStorage.theme = 'dark'
  }
}

Now localStorage will contain the value, but we have to update our preemptive class-adding code in the head to respect this setting, instead of merely adding a dark mode based on the media query.

This gets a bit more complex. We need to check

  • Has the user ever set a theme?
  • If not, does the user use a dark theme? If so, respect the media query.
  • If so, and it was dark, add the dark class
  • if so, and it was not dark (ie light), remove any dark class

That is all handled by the if statement below.

if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
  document.querySelector('html').classList.add('dark')
} else {
  document.querySelector('html').classList.remove('dark')
}

5. Detect Changes to Operating System Theme

If the user doesn't ever select a dark mode, there's a possibility they may change the theme of the operating system. It may even change automatically at night time, so we'll want our webpage to adapt.

We can add an event listener to the matchMedia object to do this.

First we'll extract our theme updating code to a function so we can reuse it easily, then have the event listener call it. Note the replacement of the if/else block with a ternary to clean up the code a little.

const matchMediaDark = window.matchMedia('(prefers-color-scheme: dark)')
const htmlClassList = document.querySelector('html').classList

// Set the 'dark' class according to localStorage 'theme' setting.
// Else if there's no stored 'theme', set the class per the system.
function updateTheme() {
  localStorage.theme === 'dark' || (!('theme' in localStorage) && matchMediaDark.matches)
    ? htmlClassList.add('dark')
    : htmlClassList.remove('dark')
}

// Apply theme when page loads.
updateTheme()

// Update 'dark' class when system theme is changed.
matchMediaDark.addEventListener('change', updateTheme)

6. Putting It Together

Put it all together and it looks like this.

<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width">

  <script>
    /** Apply theme inline in head to avoid flash of unstyled content. */
    const matchMediaDark = window.matchMedia('(prefers-color-scheme: dark)')
    const htmlClassList = document.querySelector('html').classList

    // Set the 'dark' class according to localStorage 'theme' setting.
    // Else if there's no stored 'theme', set the class per the system.
    function updateTheme() {
      localStorage.theme === 'dark' || (!('theme' in localStorage) && matchMediaDark.matches)
        ? htmlClassList.add('dark')
        : htmlClassList.remove('dark')
    }

    // Apply theme when page loads.
    updateTheme()

    // Update 'dark' class when system theme is changed.
    matchMediaDark.addEventListener('change', updateTheme)

    // Toggle localStorage theme between 'dark' and 'light' then update the theme.
    function toggleTheme() {
      htmlClassList.contains('dark')
        ? localStorage.theme = 'light'
        : localStorage.theme = 'dark'

      updateTheme()
    }
  </script>

  <style>
    body {
      font-family: sans-serif;
      background-color: hsl(0deg 0% 80%);
      color: black;
    }

    .dark body {
      background-color: hsl(0deg 0% 10%);
      color: white;
    }

    a {
      color: hsl(200deg 100% 30%);
    }

    .dark a {
      color: skyblue;
    }

    button {
      background-color: transparent;
      color: black;
      border-width: 1px;
      border-radius: 6px;
      padding:8px;
    }

    .dark button {
      color: white;
    }
  </style>
</head>

<body>
  <div>
    <button onclick="toggleTheme()">Toggle Theme</button>
  </div>

  <h1>Dark Mode Switcher Demo</h1>

  <p>
    Click the button above to switch themes!<br>
    All this really does is add or remove the 'dark' class from the parent HTML element.
  </p>

  <a href="#">Link Example</a>
<body>
</html>

You can try this out with the CodePen below, which has a few additional touches.

I hope this tutorial has been useful. Feel free to ask questions below and follow me on Twitter for more PHP and web development tips!