Astro Dark Mode

December 20, 2022 3 min read

How I added color mode support to my little Astro site.

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:

  1. I only save the user’s color mode choice when it differs from their system preference.
  2. 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.

Flow chart depicting the above-described functionality for three different events: the loader loading the page, the user clicking the mode toggle, and the OS setting being changed

All I did was remove the last localStorage.setItem() line from Kevin’s layout example:

Layout.astro
<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:

ThemeToggle.vue
<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

  1. Hilariously enough, it took vastly more time to prepare this article for code highlighting and including a filename ahead of a fenced code block.