Astroサイトのダークモード実装メモ:data属性+CSS変数で切り替える
Astro+Tailwind CSS 4の組み立て方 では、色をデザイントークンとして CSS 変数に寄せるところまで進めました。あの時点では「ダークモードの下準備」と書いて終わっていたので、今回はその続きです。実際に切り替えを動かす側——属性の付け替え、初期値の決め方、保存の扱い——を順にまとめます。
このサイトの方針はシンプルで、<html> の data-theme 属性を切り替えたら CSS 変数の中身が差し替わる、という一点に寄せています。色の定義を増やすのではなく、変数の値を2セット持つだけ。そうしておくと、コンポーネント側はテーマを意識せず var(--color-bg) のような参照だけを書けばよくなります。
方式は2つある
ダークモードの実現方法は、大きく分けると次の2つです。
| 方式 | 切り替えの判定 | ボタンの設置 | 向いているケース |
|---|---|---|---|
| メディア戦略 | OS の prefers-color-scheme に自動追随 | 不要 | 切り替え UI を持ちたくない、OS 設定に任せきりでよい |
| 属性+手動切替 | <html> の属性(クラスや data 属性)を JS で付け替える | 必要 | 読者がその場で選べるようにしたい、初期値をサイト側でも扱いたい |
メディア戦略は実装がほぼゼロで済みます。Tailwind CSS でも dark: バリアントのデフォルトは prefers-color-scheme 基準なので、設定なしで OS に追随します。ただ、OS はダークでも「このサイトは明るく読みたい」という場面は普通にあって、その受け皿がありません。
このサイトでは読者側で選べることを優先して、属性+手動切替を採りました。代償として、初期表示のチラつき対策と設定の保存という、メディア戦略にはない仕事が増えます。その2つがこの記事の中心です。
実装の流れ
全体の部品は4つです。
- CSS 変数を
data-themeの値ごとに差し替える - Tailwind の
dark:バリアントを data 属性に追随させる <head>のインラインスクリプトで、描画前に初期テーマを決める- 切り替えボタンが属性を書き換え、選択を
localStorageに保存する
1. CSS変数の差し替え
土台は通常の CSS です。:root にライト側の値を置き、[data-theme="dark"] でダーク側に上書きします。
:root {
--color-bg: #fafaf8;
--color-text: #26241f;
--color-border: #e3e0d8;
color-scheme: light;
}
[data-theme="dark"] {
--color-bg: #1b1a17;
--color-text: #ece9e2;
--color-border: #3a382f;
color-scheme: dark;
}
color-scheme を一緒に切り替えておくと、スクロールバーやフォーム部品などブラウザ標準の見た目もテーマに揃います。地味ですが、ここが漏れるとダークな画面に白いスクロールバーが残って、思いのほか目立ちます。
2. Tailwindの dark: を属性に合わせる
Tailwind CSS 4 では、dark: が効く条件を CSS 側で上書きできます。クラスではなく data 属性で判定させたい場合は、@custom-variant を属性セレクタで書きます。
@import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
これで dark:bg-neutral-900 のようなユーティリティが、data-theme="dark" の配下でのみ効くようになります。
使い分けの感覚としては、背景・文字・境界線といったサイト全体の色は CSS 変数側で寄せておき、どうしても個別に変えたい部品だけ dark: で受ける、くらいがちょうどいいです。両方を同じ強さで使うと、色の出どころが2系統になって後から追いにくくなります。
3. 描画前に初期テーマを決める
手動切替方式で一番気を使うのがここです。テーマの判定を普通のスクリプトでやると、ページがライトで描画された後にダークへ切り替わり、一瞬チラつきます(いわゆる FOUC)。防ぐには、ブラウザが本文を描く前に、保存済みの設定と OS の設定を読んで属性を付けてしまう必要があります。
Astro の場合、通常の <script> はバンドルされて type="module" として扱われるため、実行は描画後になりがちです。そこで初期化だけは is:inline を付け、書いたとおりの形で <head> に置きます。
<script is:inline>
(function () {
var stored = null;
try {
stored = localStorage.getItem("pastora-theme");
} catch (e) {
stored = null;
}
var theme =
stored === "light" || stored === "dark"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.dataset.theme = theme;
})();
</script>
優先順位は「保存済みの選択 → OS の設定 → ライト」です。localStorage の読み取りを try で囲っているのは、ブラウザの設定でストレージが無効な環境だと例外になるためで、そこでは OS 追随に落ちれば十分だと考えています。
注意:インラインスクリプトの置き場所 このスクリプトは
<head>内、かつスタイルの適用より前に実行される位置に置くのが前提です。レイアウトコンポーネントの末尾など本文側に置くと、描画が先に進んでチラつきが戻ってきます。またis:inlineのスクリプトは Astro の処理を通らないため、TypeScript 構文や import は使えません。初期化は素の JavaScript で小さく書き、残りの処理と分けておくのが無難です。
4. 切り替えボタンと保存
ボタン側の仕事は、属性を書き換えて、その結果を保存することだけです。
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
try {
localStorage.setItem("pastora-theme", theme);
} catch (e) {
// 保存できなくても、そのページでの切り替え自体は有効にする
}
}
「OS に任せる」に戻す選択肢を用意する場合は、値を保存するのではなくキーを消します。キーが無ければ初期化スクリプトが OS 設定を見に行くので、3状態(ライト/ダーク/システム)の扱いが自然に揃います。Tailwind の公式ドキュメントでも、三方向の切り替えはこの「キーを消す」方式で例示されています。
保存先を localStorage にするかは好みが分かれるところですが、テーマ程度の情報なら永続保存で困ることはほぼありません。逆に、保存しない設計にするとページを移動するたびに初期化が OS 判定から始まるので、OS と違うテーマを選んだ読者は毎回切り替え直すことになります。
細部の対応
切り替えの骨格ができても、残る違和感は細部に出ます。
- コードブロック:シンタックスハイライトがライト用テーマのままだと、ダークな本文の中でブロックだけ明るく浮きます。Astro が使う Shiki にはライト/ダークの2テーマを出力して CSS 変数で切り替える方法があるので、サイト側の属性切り替えに連動させるのが素直です(コードブロック全般の運用は Markdown記事のコードブロック運用メモ(Astro+Shiki前提) にまとめました)
- 画像:写真はそのままが無難です。一方、ロゴやアイコンのような単色素材は、色を固定せず
currentColorや CSS 変数に寄せておくと、テーマ側で受けられます。明暗で別画像を出したい場合は<picture>とmedia="(prefers-color-scheme: dark)"の組み合わせもありますが、これは手動切替ではなく OS 判定になる点に注意が要ります - 境界線と影:ダーク側では影が見えにくくなるので、要素の区切りは影よりも境界線の色差に寄せた方が崩れにくいです
公開前に見ているのは、このあたりです。
- 保存済み設定がある状態で再読み込みしても、チラつかずに選んだテーマで開く
- 初回訪問(保存なし)で、OS がダークならダークで開く
- 「システムに戻す」でキーが消え、OS の変更に追随する
- スクロールバーとフォーム部品がテーマに揃っている
- コードブロックと画像が浮いていない
まとめ
ダークモードの実装は、色を2倍に増やす作業ではなく、切り替えの判定をどこに置くかを決める作業でした。このサイトでは判定を data-theme 一箇所に寄せ、CSS 変数と Tailwind の両方をそこに従わせています。初期表示のチラつきだけは後付けで直しにくいので、インラインスクリプトの初期化を最初に固めておくのがおすすめです。
参考
- Tailwind CSS「Dark mode」: https://tailwindcss.com/docs/dark-mode
- Astro「Scripts and event handling」: https://docs.astro.build/en/guides/client-side-scripts/