Astro の ViewTransitions 対応のダークモード実装とボタンの表示切り分け

バージョン

Astro 4.13.1 で確認している内容です。

Astro のダークモードについて

このサイトではダークモードの実装と切替ができるボタンを配置しています。
Astro には公式ドキュメントにて導入方法が載っているので、そちらを雛形にしました。

Viewtransitions を導入しない場合は上記だけで問題ないです。
ただページ間のシームレスな移動が当たり前に普及している昨今では、つい導入したくなる Viewtransitions を導入すると、それに対応した記述が少し必要です。

実装

以下はこのサイトでの実装です。
tailwind.config.mjs で darkMode を class にしている前提にしています。
またアイコンは react-icons を導入して置き換えています。

components/ThemeIcon.astro
---
import { FiSun, FiMoon } from "react-icons/fi";

interface Props {
	hasText?: boolean;
}
const { hasText = false } = Astro.props;
---

<div class={`relative group/ThemeIcon ${hasText ? "has-text" : ""}`}>
  <button id="themeToggle" class="absolute inset-0 rounded-full overflow-hidden" transition:persist aria-label="外観を切り替える">
    <div class="group-[.has-text]/ThemeIcon:!hidden w-full h-full dark:hidden" data-tooltip-id="global-tooltip" data-tooltip-content="ページを暗くする"></div>
    <div class="group-[.has-text]/ThemeIcon:!hidden w-full h-full hidden dark:block" data-tooltip-id="global-tooltip" data-tooltip-content="ページを明るくする"></div>
  </button>
  {hasText ? (
    <div class="flex items-center gap-2 border py-2 px-4 rounded-full">
      <FiSun className="dark:hidden text-xl" />
      <div class="dark:hidden">ページを暗くする</div>
      <FiMoon className="hidden dark:block text-xl" />
      <div class="hidden dark:block">ページを明るくする</div>
    </div>
  ) : (
    <FiSun className="dark:hidden text-2xl" />
    <FiMoon className="hidden dark:block text-2xl" />
  )}
</div>

<script is:inline>
  function setDarkMode(document) {
    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");
    }

    localStorage.setItem("theme", theme);
  }

  setDarkMode(document);

  document.addEventListener("astro:before-swap", event => {
    setDarkMode(event.newDocument);
  });

  const handleThemeToggleClick = () => {
    const element = document.documentElement;
    element.classList.toggle("dark");

    const isDark = element.classList.contains("dark");
    localStorage.setItem("theme", isDark ? "dark" : "light");
  };

  document.getElementById("themeToggle").addEventListener("click", handleThemeToggleClick);

  if ('ontouchstart' in window || navigator.maxTouchPoints) {
    document.getElementById("themeToggle").querySelectorAll("div").forEach(div => {
      div.style.display = "none";
    });
  }
</script>

ミソは <button> タグの transition:persistdocument.addEventListenerastro:before-swap です。

transition:persist ディレクティブを使用すると、ページ間のナビゲーションでコンポーネントとHTML要素を(置き換えるのではなく)保持できます。

これにより <button> タグが置き換えられないので Viewtransitions 環境下でもクリックイベントが維持されるようになります。

また document.addEventListenerastro:before-swap はページ移動時に毎回するイベントです。
こちらでダークモードかどうかを確認する処理を入れないと、ダークモード中なのにライトモードに変わってしまった!などのちぐはぐな表示になってしまいます。

ボタンの表示切り分けについては、tailwind の group を利用して表示・非表示で対応しました。

pages/index.astro
<!-- トップページではテキストありの表示。 -->
<ThemeIcon hasText={true} />
pages/about.astro
<!-- その他のページではテキストなしの表示。 -->
<ThemeIcon />

これで ViewTransitions 対応のダークモード実装とボタンの表示切り分けができました。

余談

Astro のインテグレーション機能を使って React で書くと分かりやすく書けそうですが、Astro アイランドで完結させたかったので、この実装となりました。

カケラログ

雑多なブログ。
ふらふら移動しないですべてをここに書いていきたい。