Hero image
作成日:

Astro+Tailwind CSS v4でのダークモードの実装


目次

はじめに

本ブログにも最初に投降した記事高速、低コスト、シンプルなブログを作成できるAstroとFront Matter CMSの魅力で言及したダークモードをようやく実装しました。

今回は、AstroTailwind CSS v4 を使ったサイトにダークモードを実装する方法について解説します。0からの実装ではなく、Tailwind CSS のテーマ機能を利用していることを前提としています。

dark:bg-primaryのような指定でダークモードとの色を切り替える方法ではなく、bg-primaryの指定はそのままでテーマを切り替えることを目指します。 また、Astro の*ClientRouter(旧ViewTransitions)*を利用している場合でもスムーズに動作するような考慮点も紹介します。

Tailwind CSS部分はこちらの記事を参考にしました。

Astro単体での実装はAstro Tutorial🔗をご覧ください。

この記事の前提

この実装を進めるにあたって、以下の環境や知識があることを前提としています。

  • Astro の基本的な使い方を理解していること
  • Tailwind CSS バージョン4.xを使用していること
  • Tailwind CSS での色の管理には、CSSファイル内の@themeブロック(Tailwind CSS Theme Documentation🔗を参照)を使用していること。これにより、text-primarybg-primary といったクラス名でテーマカラーを指定できる状態を想定しています。

たとえば、以下のように @theme でカスタムプロパティを定義しているようなケースです。

@import "tailwindcss";

@theme {
  --font-kosugi: "Kosugi Maru", sans-serif;

  --color-primary: oklch(0.5 0.1 30);
  --color-primary-content: oklch(1 0 0);
  --color-base-100: oklch(0.95 0.03 75);
  --color-base-200: oklch(0.95 0.038 75.164);
  --color-base-300: oklch(0.9 0.076 70.697);
  --color-base-content: oklch(0.4 0.123 38.172);
  /* ... */
}

目指すダークモード設定

今回の実装で目指すのは、以下の2点です。

  • HTMLのクラス指定でdark:bg-primaryのようにdark:プレフィックスを都度書くのではなく、bg-primaryのようなシンプルなクラス指定のまま、ダークモードとライトモードで色が切り替わるように
  • ユーザーが手動でダークモードとライトモードを切り替えられるボタンの実装

実装手順

それでは、具体的な実装手順を見ていきましょう。

1. 初期表示時のテーマ設定スクリプト

まず、ページ読み込み時にユーザーのOS設定や以前の選択に基づいてテーマを適用するスクリプトを記述します。 このスクリプトは、ページのちらつき(FOUC: Flash Of Unstyled Content)を防ぐために、headerタグの中などHTMLの早い段階で実行されるようにします。

src/components/ThemeInitializer.astro

---

---

<script is:inline>
  // ダークモードの設定を読み込む
  const theme = (() => {
    const localStorageTheme = localStorage?.getItem("theme") ?? "";
    if (["dark", "light"].includes(localStorageTheme)) {
      return localStorageTheme;
    }
    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");
  }
  window.localStorage.setItem("theme", theme);
</script>

このスクリプトは、まずlocalStorageに保存されたテーマ設定を確認し、なければOSのダークモード設定 (prefers-color-scheme) を参照します。どちらもなければデフォルトでライトモードとします。 そして、決定したテーマに応じて htmlタグにdarkクラスを付与または削除し、その選択をlocalStorageに保存します。

Layout等のheadタグの中に組み込んでください。

src/layouts/Layout.astro

---
import ThemeInitializer from "../components/ThemeInitializer.astro";
---

<html lang="ja">
  <head>
    <ThemeInitializer />
    {/* other head content */}
  </head>
  <body>
    <slot />
  </body>
</html>

2. テーマ切り替えボタンコンポーネント

次に、ユーザーが手動でテーマを切り替えるためのボタンコンポーネントを作成します。 ここではアイコンは自分の好きなアイコンを用意してください。ここではIcon.astroというアイコン表示用のコンポーネントを別途用意しています。

src/components/ThemeToggle.astro

---
import Icon from "./Icon.astro";
---

<button
  id="theme-toggle"
  type="button"
  class="text-secondary-content hover:text-accent"
  aria-label="ダークモード切り替え"
>
  <Icon name="mdi:theme-light-dark" size={24} />
</button>

<script is:inline>
  const themeToggle = document.getElementById("theme-toggle");

  if (themeToggle) {
    themeToggle.addEventListener("click", () => {
      const element = document.documentElement;
      element.classList.toggle("dark");

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

ボタンがクリックされるたびに htmlタグのdarkクラスをトグルし、現在の状態をlocalStorageに保存します。

このテーマ切り替えボタンコンポーネントを自身のHeaderの部分に組み込んでください。ここではHeader.astroというコンポーネントを別途用意しています。

src/components/Header.astro

---
import DarkModeToggle from "../DarkModeToggle.astro";
---

<header>
  <nav>
    <DarkModeToggle />
    {/* other header content */}
  </nav>
</header>

3. Tailwind CSS の設定

ここが今回のキモとなる部分です。CSSカスタムプロパティを使ってテーマごとの色を定義します。 @layer base内で、:root(ライトモード時)と.dark(ダークモード時セレクターそれぞれに対して、これらのCSSカスタムプロパティの具体的な色値を設定します。 そして、その変数を使って@themeディレクティブ内で、使用する色の名前(例:--color-primary)を定義します。

@import "tailwindcss";

@theme {
  --font-kosugi: "Kosugi Maru", sans-serif;

  --color-primary: var(--primary);
  --color-primary-content: var(--primary-content);
  --color-base-100: var(--base-100);
  --color-base-200: var(--base-200);
  --color-base-300: var(--base-300);
  --color-base-content: var(--base-content);
  /* ... */
}

@layer base {
  /* ライトモード(デフォルト)のテーマ色 */
  :root {
    --primary: oklch(0.5 0.1 30);
    --primary-content: oklch(1 0 0);
    --base-100: oklch(0.95 0.03 75);
    --base-200: oklch(0.95 0.038 75.164);
    --base-300: oklch(0.9 0.076 70.697);
    --base-content: oklch(0.4 0.123 38.172);
    /* ... */
  }

  /* ダークモード時のテーマ色 */
  .dark {
    --primary: oklch(0.35 0.1 30);
    --primary-content: oklch(0.91 0.0084 90);
    --base-100: oklch(0.2 0.0327 67.34);
    --base-200: oklch(0.28 0.0458 71.63);
    --base-300: oklch(0.4 0.0564 70);
    --base-content: oklch(0.91 0.0107 39.44);
    /* ... */
  }
}

こうすることで、bg-primaryのようなクラスを使った場合、htmlタグにdarkクラスが付いているかどうかで、--primaryの値が自動的に切り替わり、結果として背景色が変わるという仕組みです。

これにより、HTML側でdark:プレフィックスを記述する必要がなくなります。

4. [オプション] ClientRouter との連携

AstroClientRouter(旧ViewTransitions) を使用している場合、ページ遷移はクライアント側で行われます。

このとき、is:inlineで記述されたスクリプトも、通常のページ読み込み時とは異なる挙動をすることがあります。 具体的には、テーマを適用するスクリプトやテーマを切り替えるボタンの処理が、ページ遷移後に動作しなくなる可能性があります。

そのためには、Astroが提供するライフサイクルイベントを監視して適切なタイミングでスクリプトを実行させる必要があります。

参考: ページナビゲーション中のスクリプトの動作🔗

テーマを適用するロジックを関数にまとめ、astro:after-swapイベント発生時にも呼び出されるようにします。

astro:after-swapは、新しいページの内容がDOMに挿入された直後に発火します。

src/components/ThemeInitializer.astro

---

---

<script is:inline>
  // ダークモードの設定を読み込む

+ const applyTheme = () => {
    const theme = (() => {
      const localStorageTheme = localStorage?.getItem("theme") ?? "";
      if (["dark", "light"].includes(localStorageTheme)) {
        return localStorageTheme;
      }
      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");
    }
    window.localStorage.setItem("theme", theme);
+ };

+ document.addEventListener("astro:after-swap", applyTheme);
  applyTheme();
</script>

同様に、テーマ切り替えボタンのイベントリスナーを設定する処理も関数化し、astro:page-loadイベント発生時に呼び出します。 astro:page-loadは、すべての要素が読み込まれ、ページが完全にインタラクティブになった後に発火します。これにより、新しいページでもボタンが正しく機能するようになります。

src/components/ThemeInitializer.astro

---
import Icon from "./Icon.astro";
---

<button
  id="theme-toggle"
  type="button"
  class="text-secondary-content hover:text-accent"
  aria-label="ダークモード切り替え"
>
  <Icon name="mdi:theme-light-dark" size={24} />
</button>

<script is:inline>
+ const clickHandleToggleTheme = () => {
    const themeToggle = document.getElementById("theme-toggle");

    if (themeToggle) {
      themeToggle.addEventListener("click", () => {
        const element = document.documentElement;
        element.classList.toggle("dark");

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

+  document.addEventListener("astro:page-load", clickHandleToggleTheme);
</script>
ダークモード切り替えボタンの動作

まとめ

今回は、Astro + Tailwind CSS v4 環境で、CSS Custom Properties を活用したダークモード実装方法をご紹介しました:

色の定義をCSS Custom Propertiesに集約することで、将来的なテーマの変更や追加も容易になります。 ぜひ、ご自身のAstroプロジェクトで試してみてください

参考文献

もしこのブログが役に立ったら、コーヒーを一杯おごってくれると嬉しいです。

ko-fi でコーヒー一杯おごる