こんにちは、えふてぃです。
最近、自分のAstroブログにダークモード機能を実装しました。
「目に優しい表示にしたい」「夜でも快適に読みたい」という思いから始めた実装でしたが、思っていたよりも多くの課題に直面しました(最初は簡単にできるだろうなぁと思ってた)
FOUC(Flash of Unstyled Content)の問題、スマホでの動作不具合、コードブロックの色設計など、実際に作ってみて初めて分かることばかりでした。
この記事では、そうした試行錯誤のプロセスを含めて、Astroでダークモードを実装する方法を解説します。
環境
- Astro: 5.x
- Tailwind CSS: 4.x
- Node.js: 22.x
本記事は上記の環境で検証していますが、より新しいバージョンでも同様に実装可能です。
ダークモード実装の全体像
Astroでのダークモード実装は、大きく分けて以下の3つのステップで進めました。
- 基本設定: TailwindとAstroでダークモードを有効化
- トグルボタンの実装: ユーザーが切り替えられるボタンの作成
- 全コンポーネント・ページへのスタイル適用: ダークモード時の色を個別に指定
それぞれ見ていきましょう。
ステップ1: 基本設定
1-1. Tailwind CSS でダークモードを有効化
まずは tailwind.config.js でダークモードを有効にします。
// tailwind.config.js
export default {
darkMode: 'class', // classベースのダークモードを有効化
// ...他の設定
}
darkMode: 'class' はHTML要素にclassを付けることでダークモードを切り替える方式。JavaScriptで動的に制御できます。
darkMode: 'media' はOSのシステム設定(prefers-color-scheme)のみに連動する方式です。
今回は 'class' を選択し、JavaScriptで「ユーザーの手動切り替え」と「システム設定の自動検知」の両方に対応しました。
1-2. CSS変数でダークモード用の色を定義
次に、global.css でダークモード用の色を定義します。
/* src/styles/global.css */
/* ライトモード */
:root {
--accent: #2d6a4f;
--accent-dark: #1b4332;
--text-primary: #333;
--text-secondary: #666;
/* ...その他の色変数 */
}
/* ダークモード */
:root.dark {
--accent: #52b788;
--accent-dark: #40916c;
--text-primary: #e0e0e0;
--text-secondary: #999;
/* ...その他の色変数 */
}
/* bodyの背景グラデーション */
:root.dark body {
background: linear-gradient(
to bottom,
#0a1f14 0%,
#0d2418 20%,
#10291c 40%,
#132e20 60%,
#163324 80%,
#1a3d2e 100%
);
background-attachment: fixed; /* 重要!スクロール時に背景が動かないように */
color: #e0e0e0;
}
最初は background-attachment: fixed を忘れていて、スクロールするたびに背景のグラデーションが繰り返し表示される問題がありました。
これを追加することで、背景がビューポートに固定され、自然なグラデーションになります。
1-3. FOUC対策: ページ読み込み前にテーマを適用
FOUC(Flash of Unstyled Content)とは、ページ読み込み時に一瞬だけライトモードが表示されてしまう現象です。
これを防ぐために、BaseHead.astro で is:inline スクリプトを使ってページ読み込み前にテーマを適用します。
---
// src/components/BaseHead.astro
---
<head>
<!-- ...その他のmeta情報 -->
<script is:inline>
// ページ読み込み前にテーマを適用(FOUC対策)
const theme = (() => {
// 優先順位: localStorage → システム設定 → デフォルト
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 === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
</head>
Astroの is:inline ディレクティブは、スクリプトをバンドルせずにHTMLに直接埋め込みます。
これにより、JavaScriptのロード前に実行され、FOUCを防げます。
ステップ2: トグルボタンの実装
次に、ユーザーがダークモードを切り替えるボタンを作成します。
2-1. ThemeToggle コンポーネントの作成
---
// src/components/ThemeToggle.astro
---
<button
class="theme-toggle flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 bg-white dark:bg-[#10291c] border border-[#eee] dark:border-[#555]"
aria-label="ダークモードとライトモードを切り替える"
>
<span class="moon-icon hidden dark:inline-block">🌙</span>
<span class="sun-icon inline-block dark:hidden">☀️</span>
<span class="theme-text text-sm font-medium text-[#333] dark:text-[#e0e0e0]">
ダークモード
</span>
</button>
<script>
function initThemeToggle() {
const htmlElement = document.documentElement;
function updateThemeUI(isDark) {
document.querySelectorAll('.theme-toggle').forEach(button => {
button.setAttribute('aria-pressed', isDark.toString());
});
document.querySelectorAll('.theme-text').forEach(text => {
text.textContent = isDark ? 'ライトモード' : 'ダークモード';
});
}
function toggleTheme() {
const isDark = htmlElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
updateThemeUI(isDark);
}
// 初期状態の設定
const isDark = htmlElement.classList.contains('dark');
updateThemeUI(isDark);
// すべてのトグルボタンにイベントリスナーを追加
document.querySelectorAll('.theme-toggle').forEach(button => {
button.addEventListener('click', toggleTheme);
});
}
// ページ読み込み時とページ遷移時に初期化
document.addEventListener('astro:page-load', initThemeToggle);
</script>
2-2. スマホでの動作不具合と解決方法
最初に実装した際、スマホでダークモードが切り替わらない問題が発生しました。
スマホでダークモードボタンを押しても切り替わらないのですが…
原因は、PC用とモバイル用で2つのボタンが表示されているのに、IDが重複していたことです。
getElementById は最初の要素しか取得できないため、モバイル用ボタンにイベントリスナーが付いていませんでした。
修正内容:
<!-- 修正前: IDを使用 -->
<button id="theme-toggle">
<!-- 修正後: クラスを使用 -->
<button class="theme-toggle">
// 修正前: 単一の要素のみ取得
const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', toggleTheme);
// 修正後: 全ボタンを取得
document.querySelectorAll('.theme-toggle').forEach(button => {
button.addEventListener('click', toggleTheme);
});
普通に凡ミスだった(恥ずかしい)
解決できたのでヨシ!
ステップ3: 全コンポーネント・ページへのスタイル適用
ここからが本番です。
ダークモード対応は、すべてのページとコンポーネントに個別にスタイルを指定する必要があります。
3-1. 使用色の統一
まず、ダークモード時の色を統一することで、デザインに一貫性を持たせます。
| 用途 | ライトモード | ダークモード |
|---|---|---|
| カード背景 | bg-white | dark:bg-[#10291c] |
| 見出しテキスト | text-[#333] | dark:text-[#e0e0e0] |
| 説明文テキスト | text-[#666] | dark:text-[#999] |
| ボーダー | border-[#eee] | dark:border-[#555] |
| カテゴリタグ背景 | bg-primary-lighter | dark:bg-[#163324] |
| ホバー背景 | hover:bg-gray-50 | dark:hover:bg-[#163324] |
3-2. ページごとの対応例
記事一覧ページ
---
// src/pages/blog/[...page].astro
---
<h1 class="text-4xl font-bold text-[#333] dark:text-[#e0e0e0]">
ブログ一覧
</h1>
<div class="bg-white dark:bg-[#10291c] rounded-lg shadow-md">
<h2 class="text-xl font-semibold text-[#333] dark:text-[#e0e0e0]">
記事タイトル
</h2>
<p class="text-[#666] dark:text-[#999]">
記事の説明文...
</p>
</div>
カテゴリページ
---
// src/pages/categories/index.astro
---
<div class="bg-white dark:bg-[#10291c] rounded-lg shadow-md border border-[#eee] dark:border-[#555]">
<h3 class="text-lg font-semibold text-[#333] dark:text-[#e0e0e0]">
カテゴリ名
</h3>
<span class="bg-primary-lighter dark:bg-[#163324] text-primary dark:text-primary-dark">
10件
</span>
</div>
3-3. コンポーネントの対応例
Tableコンポーネント
Tableコンポーネントでは、:global(:root.dark) を使ってダークモード用スタイルを適用しました。
---
// src/components/Table.astro
---
<div class="table-wrapper border border-[#eee] dark:border-[#555]">
<table>
<slot />
</table>
</div>
<style>
/* ライトモード */
table thead {
background: linear-gradient(to bottom, #f8f9fa, #e9ecef);
border-bottom: 2px solid #dee2e6;
}
/* ダークモード */
:global(:root.dark) table thead {
background: linear-gradient(to bottom, #132e20, #10291c);
border-bottom: 2px solid #555;
}
:global(:root.dark) table thead th {
color: #e0e0e0;
}
:global(:root.dark) table tbody tr {
border-bottom: 1px solid #555;
}
:global(:root.dark) table tbody tr:hover {
background-color: #163324;
}
:global(:root.dark) table tbody td {
color: #e0e0e0;
}
</style>
Astroのコンポーネントスタイルはデフォルトでスコープされています。
:global() を使うことで、グローバルなセレクタ(:root.dark など)を適用できます。
実装中に直面した問題と解決方法
実装中に直面した主な問題を、以下の表にまとめました。
| 問題 | 原因 | 解決方法 |
|---|---|---|
| コードブロック内に緑の背景が表示される | インラインコード用の背景色が、コードブロックにも適用されていた | pre > code に background: none !important; を追加 |
| 背景のグラデーションがスクロール時に切れ目が見える | background-attachment: fixed が設定されていなかった | background-attachment: fixed; を追加 |
| ページネーションボタンのコントラストが低い | ボタンの視認性が低く、クリックできる要素だと分かりにくい | ホバー時に色を変えてインタラクティブ性を向上 |
以下、各問題の詳細を解説します。
問題1: コードブロック内に緑の背景が表示される
シンタックスハイライトされたコードブロック内のコード文字に、インラインコード用の緑背景が適用されていました。
原因:
インラインコード(code)用の背景色が、コードブロック(pre > code)にも適用されていたため。
解決方法:
/* global.css */
/* インラインコード */
:root.dark code {
background-color: rgba(22, 51, 36, 0.6);
color: #e0e0e0;
}
/* コードブロック */
:root.dark pre {
background-color: #0d2418;
}
/* コードブロック内のcode要素は背景なし */
pre > code {
all: unset;
background: none !important; /* 追加 */
color: inherit; /* 追加 */
}
これにより、インラインコードとコードブロックで異なるスタイルを適用できました。
問題2: 背景のグラデーションがスクロール時に切れ目が見える
スクロールすると、背景のグラデーションが繰り返し表示されていました。
原因:
background-attachment: fixed が設定されていなかったため、背景がコンテンツと一緒にスクロールしていました。
解決方法:
:root.dark body {
background: linear-gradient(
to bottom,
#0a1f14 0%,
#0d2418 20%,
#10291c 40%,
#132e20 60%,
#163324 80%,
#1a3d2e 100%
);
background-attachment: fixed; /* これを追加 */
color: #e0e0e0;
}
ライトモードでは設定されていたのに、ダークモードでは抜けていたという凡ミスでした。
問題3: ページネーションボタンのコントラストが低い
ページネーション(ページ送り)のボタンが見づらく、クリックできる要素だと分かりにくい問題がありました。
解決方法:
<a
href={`/blog/${currentPage - 1}`}
class="px-4 py-2 rounded-lg border border-[#ccc] dark:border-[#555] bg-white dark:bg-[#10291c] text-[#333] dark:text-[#e0e0e0] hover:bg-gray-50 dark:hover:bg-[#163324] dark:hover:border-primary-dark dark:hover:text-primary-dark transition-colors"
>
前へ
</a>
ホバー時に色を変えることで、インタラクティブ性を高めました。
アクセシビリティへの配慮
ダークモードの実装では、アクセシビリティも重要になってきます。
aria属性の追加
<button
class="theme-toggle"
aria-label="ダークモードとライトモードを切り替える"
aria-pressed="false"
>
<!-- ボタン内容 -->
</button>
- aria-label: スクリーンリーダーでボタンの機能を説明
- aria-pressed: ダークモードのオン/オフ状態を明示
カラーコントラストの確保
WCAG(Web Content Accessibility Guidelines)AA準拠を目指し、以下のコントラスト比を確保しました。
- 見出しテキスト:
#e0e0e0(明度高め) - 本文テキスト:
#999(読みやすさとコントラストのバランス) - 背景: 深い緑系グラデーション
コントラスト比は、Chrome DevToolsの「Lighthouse」で検証しました。
まとめ: ダークモード実装で学んだこと
Astroブログにダークモードを実装する過程で、以下のポイントが重要だと分かりました。
- FOUC対策は必須:
is:inlineスクリプトでページ読み込み前にテーマを適用 - 色の統一が大事: カード背景、テキスト、ボーダーなどを統一することでデザインに一貫性が生まれる
- 細かい調整が多い: 全ページ・全コンポーネントに個別対応が必要で、地道な作業が求められる
- アクセシビリティも忘れずに: aria属性やカラーコントラストを意識することで、誰にでも使いやすいサイトになる
最初は「ダークモードって簡単に実装できるでしょ」と思っていましたが、実際には思ったよりも奥が深い機能でした。
それでも、夜にブログを読むときの快適さが格段に向上したので、実装して本当に良かったと感じています。 ブログ書くときにずっと明るい画面で見ると目がギンギンになるので個人的にも良かった。
ぜひ、皆さんもAstroブログにダークモードを実装してみてください!
参考リンク
公式ドキュメント
Astro関連
- Astro - Template Directives Reference (is:inline)
- Astro - View Transitions (astro:page-load)
- Astro - Styles and CSS (:global())
アクセシビリティ
このブログが面白いと思った方は、是非とも下のボタンをクリックして、記事シェアやフォローをしていただけると嬉しいです!
📚 関連記事
【第2章】Meta タグとOGP設定でSNSシェアを最適化!Astroブログで実装してみた
SEO対策の第2章として、Meta タグとOGP設定をAstroブログに実装してみました。検索結果やSNSシェアの見栄えを改善する方法を、実際のコード例と試行錯誤のプロセスを交えて解説します。
Claude Code CLIのカスタムコマンドでブログ記事の品質チェックを効率化した話
Claude Codeのカスタムコマンド機能を使って、ブログ記事の事実確認を自動化する方法を紹介。技術記事や雑記記事の信頼性を高めるための実装例を解説します。
【第1章】SEO対策とは?個人ブログ開発者が検索順位を上げるために調べた4つの施策
SEO対策って何をすればいいの?と思って調べてみた話をまとめました。検索エンジンで上位表示されるために重要な4つの施策(コンテンツ最適化、技術的SEO、UX、外部対策)とGoogleの評価基準(E-E-A-T)を解説します。
Claude Code CLIのカスタムコマンドでブログ運営を効率化する方法
Claude Code CLIのカスタムコマンドで、記事作成からレビューまで自動化したら爆速になった!実際に作った「ブログ作成を支援」「ブログのレビュー」コマンドのコードと使い方を全部公開。ブログ運営を効率化したい人におすすめです。