# Three-State Light/Dark Theme Switch

Source: https://tpiros.dev/blog/three-state-light-dark-theme-switch

More and more websites have bolted on a "dark" mode. The idea's simple: reduce the light blasting out of your screen so your eyes don't stage a revolt at 11pm. [Harvard Medical School research](https://www.health.harvard.edu/staying-healthy/blue-light-has-a-dark-side) shows extended blue light exposure messes with sleep. The science runs deeper than that, but we're not here for biology.

We're here to talk about building the light/dark theme switch properly. Stack Overflow and GitHub both shipped dark themes recently. After poking around dozens of sites and reading far too many articles on the subject, I found two distinct implementation approaches.

> Before I continue, I wanted to thank [Chee Aun](https://twitter.com/cheeaun) for pointing me in the right direction regarding the various state options for the light/dark theme.

# Two-state theme switch

The first approach is a "two-state" switch. You toggle between light and dark. Done. Various CSS frameworks offer their own flavour of this, including [TailwindCSS](https://tailwindcss.com/docs/dark-mode). Let's build one with Tailwind.

> Tailwind's dark mode has two options: `class` and `media`. `media` piggybacks on `prefers-color-scheme`. But if you want manual switching, you'll need `class`.

Enabling manual switching means dropping the `darkMode` property into your `tailwind.config.js` file with a value of `class`.

```javascript
// tailwind.config.js
module.exports = {
  darkMode: 'class',
  theme: {},
  variants: {
    extend: {},
  },
  plugins: [],
};
```

With that config in place, you can sprinkle `dark` variants through your markup:

```html
<button id="light">Light</button> | <button id="dark">Dark</button>
<p class="text-black dark:text-white">Hello there!</p>
```

The text shows up black in light mode and flips to white when dark mode kicks in.

So how do you actually flip the switch? A bit of JavaScript does the heavy lifting:

```javascript
const htmlClasses = document.querySelector('html').classList;
document.getElementById('dark').addEventListener('click', () => {
  htmlClasses.add('dark');
  localStorage.theme = 'dark';
  document.getElementById('light').hidden = false;
  document.getElementById('dark').hidden = true;
});

document.getElementById('light').addEventListener('click', () => {
  htmlClasses.remove('dark');
  localStorage.theme = 'light';
  document.getElementById('light').hidden = true;
  document.getElementById('dark').hidden = false;
});
```

This code adds or strips the `dark` variant from the HTML element and toggles the right button. Notice we're stashing the choice in `localStorage` too. That way, when visitors come back, we can grab their preference and apply it straight away:

```javascript
if (
  localStorage.theme === 'dark' ||
  (localStorage.getItem('theme') === null &&
    window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
  document.getElementById('dark').hidden = true;
  document.querySelector('html').classList.add('dark');
} else if (localStorage.theme === 'dark') {
  document.getElementById('dark').hidden = true;
  document.querySelector('html').classList.add('dark');
} else {
  document.getElementById('light').hidden = true;
}
```

We also check the system preference via `window.matchMedia("(prefers-color-scheme: dark)").matches` and set the matching theme based on the result.

A working example lives below.

<div class="glitch-embed-wrap" style="height: 420px; width: 100%;">
  <iframe
    src="https://glitch.com/embed/#!/embed/sweet-mint-tarsal?path=public/script.js&previewSize=100"
    title="sweet-mint-tarsal on Glitch"
    style="height: 100%; width: 100%; border: 0;">
  </iframe>
</div>

> The [code is also available](https://glitch.com/edit/#!/sweet-mint-tarsal) for the above project.

## Problems with a two-state switch

You might be thinking: what's actually wrong with this? Fair point. It works. You can swap between light and dark. I thought it was perfectly fine too, until I dug a bit deeper.

The problem: there's no way to get back to the "system" setting. If your OS runs dark mode and you manually pick a theme inside the app, you're stuck. The site can't fall back to whatever the operating system says.

The only escape hatch is clearing `localStorage` by hand. You can't expect visitors to crack open DevTools for that. Removing the `theme` entry from `localStorage` triggers this condition, and the right theme loads:

```javascript
// window.matchMedia('(prefers-color-scheme: dark)').matches will take the OS level setting
if (
  localStorage.theme === 'dark' ||
  (localStorage.getItem('theme') === null &&
    window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
  // set dark theme
}
```

# Three-state theme switch

StackOverflow and GitHub (and plenty of others) use three states: dark, light, and system. This kills the problem we just talked about. Visitors can "reset" back to the OS-level setting without touching `localStorage` themselves.

Several implementation strategies work here. If you've got a login system, tuck the theme selector into profile settings. A dropdown with three options does the job too. Any of these are solid choices.

> On this blog, you'll also find a three-state theme selector.

Here's a working example of the three-state approach:

<div class="glitch-embed-wrap" style="height: 420px; width: 100%;">
  <iframe
    src="https://glitch.com/embed/#!/embed/serious-almond-egret?path=views/index.html&previewSize=100"
    title="serious-almond-egret on Glitch"
    style="height: 100%; width: 100%; border: 0;">
  </iframe>
</div>

> The [code is also available](https://glitch.com/edit/#!/serious-almond-egret) for the above project.

You'll spot a cogwheel icon. It turns blue or yellow depending on whether the OS is running dark or light mode.

> Even though we talk about three states, we actually need to handle four: light, dark, system light, and system dark.

The logic is dead simple. If `localStorage` doesn't contain a `theme` property, the user hasn't picked anything, so the OS default takes over.

Users can still choose a theme manually. Their preference stays put until they click the cogwheel again, which wipes the `theme` property from `localStorage`.

But wait. If you're like me, your OS flips between light and dark automatically (mine follows the sunset/sunrise schedule). To catch that change on the fly, you can wire up an event listener:

```javascript
window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
  console.log('called dark');
  if (e.matches && !localStorage.theme) {
    // switch to a 'system dark' theme
  }
});

window.matchMedia('(prefers-color-scheme: light)').addListener((e) => {
  console.log('called dark');
  if (e.matches && !localStorage.theme) {
    // switch to a 'system light' theme
  }
});
```

This picks up any change to the user's system theme and applies it immediately.

Testing this is quick. Chrome DevTools can simulate dark/light mode. Open DevTools, press `SHIFT + CMD + P` (or `SHIFT + CTRL + P` on Windows), type `dark`, and you'll see `Rendering: Emulate CSS prefers-color-scheme: dark`. (Typing `light` brings up the light option.) Selecting either one forces that theme, which makes testing the system variant painless.

# FART

Yes, that title. Bear with me. `FART` is a [term Chris Coyier coined](https://css-tricks.com/flash-of-inaccurate-color-theme-fart/) and stands for `Flash of inAccurate coloR Theme`. It's the visual flicker you get when the system setting clashes with the user's preference. If someone picked dark mode but their OS runs light, you'll see a brief flash because the dark theme gets applied via JavaScript after the page renders.

This hits pre-rendered, pre-generated, and server-side rendered sites (think Jamstack) especially hard. The video below shows the problem: the page gets refreshed several times with light mode at the OS level while the user has selected dark.

<video src="https://res.cloudinary.com/full-stack-training/video/upload/f_auto,q_auto/v1619166343/dark-light-flicker_q6mcrp.mp4" muted controls></video>

## Avoiding the flicker

Different frameworks have different strategies. [Rob Morieson's solution using Next.js](https://electricanimals.com/articles/next-js-dark-mode-toggle) is one example.

There's also a simpler approach. This one works with [11ty](https://www.11ty.dev), a popular JavaScript-based static site generator.

The core issue: the page flashes when there's a mismatch between the OS theme and the user's selection. We can turn JavaScript's blocking behaviour to our advantage. Drop a `<script>` tag in the `<head>` of your HTML. The browser parses HTML line by line, so the script runs before the rest of the content renders:

```javascript
window.matchMedia('(prefers-color-scheme: dark)').matches
  ? (document.documentElement.className = 'dark')
  : (document.documentElement.className = 'light');
```

Here, `document.documentElement` points to the root `html` node. The code sets either `dark` or `light` on initial load. The JavaScript we discussed earlier in this article may or may not overwrite it, depending on the user's stored selection. With this in place, the flicker vanishes.

# Conclusion

Light and dark themes can genuinely improve how people experience your site. But getting the implementation right takes a bit of thought. We've walked through both the two-state and three-state approaches. If you can offer that system-level reset option, do it. Without it, you're locking visitors into a mode they can't easily escape.
