Dark Mode Switch Respecting System Theme
Tags: webdev, css, jav
Published: 2021-12-05 02:14:53

If you've ever wanted to have a simple "dark mode" toggle on your website, it can be a bit tricky because you should support three states: Dark, Light, and System. Additionally, there's keeping track of when the system theme changes (some people have auto dark mode at night).

What we can do is follow the system theme by default. If the user toggles the theme on our website, we'll set a `theme` item in `localStorage` to override the system theme. Then, if the user toggles the theme to match the system theme, we remove the override. The result is that the user can get the behavior they want just by switching the theme to what they want!

Here's a codepen with it in action:
{% codepen https://codepen.io/probablykasper/pen/JjrYmqx %}

You can also see my Svelte implementation of this on the [Date Picker Svelte website](https://date-picker-svelte.kasper.space/) ([app.html](https://github.com/probablykasper/date-picker-svelte/blob/86e94ce08dddd9bbf4f2fb71c038463545adf3ec/src/app.html), [layout.svelte](https://github.com/probablykasper/date-picker-svelte/blob/86e94ce08dddd9bbf4f2fb71c038463545adf3ec/src/routes/__layout.svelte)).

I've chosen dark mode as the default theme just in case there's a "Flash of incorrect theme", or FOIT. If the default theme was `light`, this flash would be bright, which is particularly distracting for dark mode users. FOIT is a risk because we need to load our theme in JavaScript, but it should rarely happen.

## The `<head>`

```html
<!-- Default theme, for when JS is disabled -->
<html lang="en" data-theme="dark">
  <head>
    <script>
      function detectTheme() {
        var themeOverride = localStorage.getItem('theme')
        if (themeOverride == 'dark' || themeOverride === 'light') {
          // Override the system theme
          return themeOverride
        } else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
          // Use the system theme
          return 'light'
        } else {
          // Default theme
          return 'dark'
        }
      }
      document.documentElement.setAttribute('data-theme', detectTheme())
    </script>
    <!-- other head tags -->
  </head>
  <!-- body -->
</html>
```

First of all, we have the `data-theme="dark"` attribute in our `<html>` element. That makes sure our theme is still specified if JavaScript is disabled.

Inside `<head>`, there's our script. We want it to run as early as possible, so it's the first thing in `<head>`. The script simply sets the `<html>` `data-theme` attribute with whatever theme it detects. It first checks the `theme` item in `localStorage`. If there's no `theme` override, it uses the system theme or the default `dark` theme.

## Updating the theme

Now for the JS that takes care of updating the theme:

```js
// Get the systemTheme using window.matchMedia
const prefersDarkMQ = matchMedia('(prefers-color-scheme: dark)')
let systemTheme = prefersDarkMQ.matches ? 'dark' : 'light'
prefersDarkMQ.onchange = (e) => {
  // Keep the systemTheme variable up to date
  systemTheme = e.matches ? 'dark' : 'light'
  // Update the theme, as long as there's no theme override
  if (localStorage.getItem('theme') === null) {
    setTheme(systemTheme)
  }
}
```

Here we get the system theme in our `systemTheme` variable. When the system theme changes, we update our `systemTheme` variable and, unless we have a theme set override set in `localStorage`, we update the theme as well.

```js
let theme = document.documentElement.getAttribute('data-theme') || 'dark'
```
Next, we make a variable for the theme, using the value from the `data-theme` attribute which was set when the page loaded.

```js
function setTheme(newTheme) {
  document.documentElement.setAttribute('data-theme', newTheme)
  theme = newTheme
  if (newTheme === systemTheme) {
    // Remove override if the user sets the theme to match the system theme
    localStorage.removeItem('theme')
  } else {
    localStorage.setItem('theme', newTheme)
  }
}
```

Finally, we define a function for updating the theme. It first updates the `data-theme` attribute and the `theme variable`. If the theme is updated to match the system theme, we remove our theme override from `localStorage`. If it's updated to something else, we set the override.

Now, for your toggle button, you just need a click handler like this:

```js
function toggleTheme() {
  if (theme === 'dark') {
    setTheme('light')
  } else {
    setTheme('dark')
  }
}
```

For your CSS you simply select the `html[data-theme]` attribute:
```css
html[data-theme="dark"] {
  --bg: black;
}
html[data-theme="light"] {
  --bg: white;
}
html {
  background-color: var(--bg);
}
```

## Behavior

- **What if the website is overridden to dark, and I want to change it to follow the system when the system theme is dark?** Toggle the theme to match the system once you notice it's not.
- **What if the website is light, and I want to change it to be overridden to dark when the system is already dark?** Toggle it to dark mode. It'll follow the system, so once you notice the website changed to light mode, toggle it to dark mode again.