One of my latest Astro adventures was adding a dark mode toggle1—actually a light mode for this site. I’m using Tailwind and mostly stole Kevin Zuniga Cuellar’s approach with two key differences:
- I only save the user’s color mode choice when it differs from their system preference.
- I used Vue for the theme switch component instead of Preact. (Though I’ll probably switch to Svelte for fun and a smaller bundle.)
The preference treatment is subtle, but it feels right to me. Clicking to switch modes visually does what you’d expect, switching from light mode to dark mode and vice versa. But that click, that explicit choice, is only saved to localStorage when it overrides the system default.
This retains the site’s ability to adapt fluidly when the system preference changes, instead of forcing an explicit click to forever shift the site in “manual” mode. It honors the immediate toggle choice, sticks permanently if it differs from the OS setting, and goes on matching the OS if the latest choice aligned with the OS preference.
All I did was remove the last localStorage.setItem()
line from Kevin’s layout example:
<script is:inline>
const theme = (() => {
if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
return localStorage.getItem("theme")
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark"
}
return "light"
})()
if (theme === "light") {
document.documentElement.classList.remove("dark")
} else {
document.documentElement.classList.add("dark")
}
</script>
My Vue theme toggle looked like this:
<template>
<button
@click="toggleTheme"
:title="
activeTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
"
>
<SunIcon v-if="activeTheme === 'dark'" />
<MoonIcon v-else />
</button>
</template>
<script>
import { SunIcon, MoonIcon } from "@heroicons/vue/24/solid/index.js"
export default {
components: {
SunIcon,
MoonIcon,
},
data() {
return {
// OS color mode
systemTheme: null,
// Whatever’s saved in localStorage, if anything
savedTheme: null,
// Whatever our UI is reflecting
activeTheme: null,
}
},
mounted() {
this.savedTheme = localStorage.getItem("theme")
this.systemTheme =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
// Honor the saved preference or system preference in that order
this.activeTheme = this.savedTheme ?? this.systemTheme
// Listen for system-level color mode changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (event) => {
const newColorScheme = event.matches ? "dark" : "light"
// Keep system preference up to date
this.systemTheme = newColorScheme
if (!this.savedTheme) {
// If we don’t have a saved theme and this one is new, update!
this.activeTheme = this.systemTheme
this.updateRootClass()
}
})
},
methods: {
toggleTheme() {
this.activeTheme = this.activeTheme === "light" ? "dark" : "light"
if (this.systemTheme !== this.activeTheme) {
// Save explicit setting to local storage
localStorage.setItem("theme", this.activeTheme)
} else {
// Remove saved preference if it matches system default
localStorage.removeItem("theme")
}
this.updateRootClass()
},
updateRootClass() {
if (this.activeTheme === "dark") {
document.documentElement.classList.add("dark")
} else {
document.documentElement.classList.remove("dark")
}
},
},
}
</script>
I ran into an initial flash problem that ended up being a result of how I was using named slots, but in the process found Josh Comeau’s meticuluous The Quest for the Perfect Dark Mode. It’s a wonderfully-thorough and illustrative exploration of the subject, complete with a dry sense of humor I adore.
Footnotes
Hilariously enough, it took vastly more time to prepare this article for code highlighting and including a filename ahead of a fenced code block. ↩