Astro docs suggest implementing a dark mode like so.

That’s great and works fine for initial implementation but runs into issues when using the ClientRouter for those j00cy bult in page transitions.

Namely it’s great toggling on first load but falls flat after navigating to different pages on your site until you hit the hard server rendered refreshed (that’s F5 to you and me).

With some workarounds you can get the best of all worlds - great light/dark (why stop at two?) themes to toggle between, persistence between client side page routes, beautiful Astro page transitions AND respecting the user’s prefers-color-scheme: dark preference set in browser/OS/cave.

tldr here’s the code for the ThemeIcon.astro component (I put mine in src/compnents/ThemeIcon.astro) and the sitewide import in the BaseLayout.


---

<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
  <span id="theme-toggle-dark-icon">☀️</span>
  <span id="theme-toggle-light-icon">🌙</span>
  <span id="theme-toggle-terminal-icon">🤖</span>
</button>

<style>
  .theme-toggle {
    border: none;
    background: none;
    cursor: pointer;
    padding: 0;
    font-size: 1.2rem;
  }
  .hidden {
    display: none;
  }
</style>

<script is:inline>
  const STORAGE_KEY = "theme";
  const THEMES = ["light", "dark", "terminal"];

  // Function to handle theme updates
  function updateTheme(newTheme) {
    // Update document classes
    document.documentElement.classList.remove(...THEMES);
    document.documentElement.classList.add(newTheme);

    // Update icons
    const darkIcon = document.getElementById("theme-toggle-dark-icon");
    const lightIcon = document.getElementById("theme-toggle-light-icon");
    const terminalIcon = document.getElementById("theme-toggle-terminal-icon");

    if (darkIcon && lightIcon && terminalIcon) {
      darkIcon.classList.toggle("hidden", newTheme !== "light");
      lightIcon.classList.toggle("hidden", newTheme !== "dark");
      terminalIcon.classList.toggle("hidden", newTheme !== "terminal");
    }

    // Store the state
    localStorage.setItem(STORAGE_KEY, newTheme);
  }

  function getNextTheme(currentTheme) {
    const currentIndex = THEMES.indexOf(currentTheme);
    return THEMES[(currentIndex + 1) % THEMES.length];
  }

  // Function to setup theme toggle
  function setupTheme() {
    // Get initial theme
    const theme = (() => {
      if (
        typeof localStorage !== "undefined" &&
        localStorage.getItem(STORAGE_KEY)
      ) {
        return localStorage.getItem(STORAGE_KEY);
      }
      return window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light";
    })();

    // Apply initial theme
    updateTheme(theme);

    // Setup click handler
    const toggle = document.getElementById("theme-toggle");
    if (toggle) {
      toggle.addEventListener("click", () => {
        const currentTheme = localStorage.getItem(STORAGE_KEY) || "light";
        const nextTheme = getNextTheme(currentTheme);
        updateTheme(nextTheme);
      });
    }
  }

  // Initial setup
  setupTheme();

  // Handle Astro view transitions
  document.addEventListener("astro:after-swap", () => {
    setupTheme();
  });

  // Handle system theme changes
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .addEventListener("change", (e) => {
      if (!localStorage.getItem(STORAGE_KEY)) {
        updateTheme(e.matches ? "dark" : "light");
      }
    });
</script>

Then chuck that in your main site layout - for me that’s in the nav in BaseLayout.astro in src/layouts/BaseLayout.astro

This is also where you would declare the page transitions with the ClientRouter import and placement in head:

---
const { title } = Astro.props;
import { ClientRouter } from "astro:transitions";
import ThemeIcon from '../components/ThemeIcon.astro';
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>{title}</title>
    <ClientRouter />
  </head>
  <body>
    <div class="container">
    // I <3 the new popover API
    // But you might want to do the nav with JS for total browser compatibility
      <button
        popovertarget="menu"
        popovertargetaction="toggle"
        class="menu-trigger">☰</button
      >
      <nav id="menu" popover="auto" class="menu-content">
        <a href="/">👀 home</a>
        <a href="/about">📖 about</a>
        <a href="/stuff">🏗 stuff</a>
        <a href="/words">💬 words</a>
        <ThemeIcon />
      </nav>
      <slot />
    </div>
  </body>
</html>
<style>

html {
    --bg-color: #ecf1f5;
    --text-color: black;
    --menu-bg: rgba(255, 255, 255, 0.2);
  }

  html.dark {
    --bg-color: #1a1625;
    --text-color: #ffffff;
    --menu-bg: rgba(0, 0, 0, 0.2);
  }
  html.terminal {
    --bg-color: #000000;
    --text-color: #00ff00;
    --menu-bg: rgba(0, 255, 0, 0.1);
  }
  html,
  body {
    height: 100%;
    margin: 0;
    font-family: "Courier New", Courier, monospace;
    color: var(--text-color);
  }
  //rest of your BaseLayout styles
  </style>

Then adjust your css colours for each html.theme as you see fit.

That’s about it - thanks for reading! alcun ✌️