Three-State Light/Dark Theme Switch
Older Article
This article was published 5 years ago. Some information may be outdated or no longer applicable.
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 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 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. Let’s build one with Tailwind.
Tailwind’s dark mode has two options:
classandmedia.mediapiggybacks onprefers-color-scheme. But if you want manual switching, you’ll needclass.
Enabling manual switching means dropping the darkMode property into your tailwind.config.js file with a value of class.
// tailwind.config.js
module.exports = {
darkMode: 'class',
theme: {},
variants: {
extend: {},
},
plugins: [],
};
With that config in place, you can sprinkle dark variants through your markup:
<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:
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:
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.
The code is also available 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:
// 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:
The code is also available 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:
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 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.
Avoiding the flicker
Different frameworks have different strategies. Rob Morieson’s solution using Next.js is one example.
There’s also a simpler approach. This one works with 11ty, 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:
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.