第18章 ダークモード
18.1 dark: バリアントの仕組み
ダークモードは、dark: バリアントで実現します。第6章で見たとおり、これは「ダークモードのときだけ効くスタイル」を生成するバリアントです。
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
ライトでは白地・黒字、ダークでは黒地・白字
</div>
無印(bg-white text-gray-900)がライトモードの見た目、dark: 付きがダークモードでの上書きです。第17章のモバイルファーストと同じ「土台+上書き」の構造です。
18.2 media(OS 連動)と class/selector(手動切替)戦略
ダークモードの「効くタイミング」には、大きく 2 つの戦略があります。
- OS 連動(既定): 利用者の OS 設定(
prefers-color-scheme)に従って自動で切り替わります。設定不要で、dark:がそのまま OS のダークモードに反応します。 - 手動切替(クラス方式): ページ内のトグルボタンで切り替えたい場合。
<html>などに.darkクラスが付いているときだけdark:を効かせます。
「ユーザーに切り替えボタンを提供したい」なら、後者の手動切替を選びます。
18.3 v4 でのダークモード設定(@custom-variant dark)
手動切替にするには、第6章で触れた @custom-variant を使って dark: の意味を上書きします。v4 では CSS にこう書きます。
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
これで dark: は「OS 設定」ではなく「自身または祖先に .dark クラスがあるとき」に効くようになります。あとは JavaScript で <html> に .dark を付け外しすれば、テーマが切り替わります。
// ダークにする / 戻す
document.documentElement.classList.toggle('dark')
v3 を知っている人へ: v3 では
tailwind.config.jsにdarkMode: 'class'と書きました。v4 ではこの設定が CSS の@custom-variantに移りました。
18.4 トグル実装とちらつき対策
手動切替で必ずぶつかるのが、初期表示のちらつき(FOUC: Flash of Unstyled Content)です。ページ読み込み時、JavaScript で .dark を付ける前に一瞬ライトモードが表示され、直後に暗転する——この不快なちらつきです。
原因は、「.dark を付ける JavaScript が、画面の描画より後に走る」ことです。対策は、描画前(<head> の早い段階)に、同期的なスクリプトで .dark を決定することです。
以下は通常の HTML ページで、ライト・ダーク・OS 連動を選び、保存・復元する例です。18.3 のカスタムバリアントを設定した CSS と組み合わせます。
<!-- <head> 内、スタイルシートより前に置く同期スクリプト -->
<script>
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
function applyTheme() {
const theme = localStorage.getItem('theme')
document.documentElement.classList.toggle(
'dark', theme === 'dark' || (theme === null && systemTheme.matches)
)
}
applyTheme()
systemTheme.addEventListener('change', applyTheme)
</script>
次を <body> に置きます。スクリプトはボタンより後に置くので、取得時にはボタンが存在します。
<div role="group" aria-label="表示テーマ">
<button type="button" data-theme-mode="light">ライト</button>
<button type="button" data-theme-mode="dark">ダーク</button>
<button type="button" data-theme-mode="system">OS に合わせる</button>
</div>
<script>
document.querySelectorAll('[data-theme-mode]').forEach((button) => {
button.addEventListener('click', () => {
const mode = button.dataset.themeMode
if (mode === 'system') {
localStorage.removeItem('theme')
} else {
localStorage.setItem('theme', mode)
}
applyTheme()
})
})
</script>
ライトまたはダークを選ぶと、再読み込み後も選択が維持されます。「OS に合わせる」は保存値を削除し、OS 設定の変更にも追従します。手動選択中は OS が変わっても選んだモードを保ちます。
この例は localStorage が利用できる環境を前提とします。Rails/Turbo ではイベントの登録・解除を Stimulus のライフサイクルに合わせます(第24章)。React/Next.js ではハイドレーションと初期クラスを整合させ、各フレームワークの初期スクリプトの扱いに従って組み込みます。上の DOM 操作をそのまま各フレームワークへ重ねて登録しないようにしてください。
なお、テーマを Cookie やデータベースに保存する設計であれば、サーバー側でユーザーの設定を知れるので、最初から <html class="dark"> をサーバーレンダリングして出力できます。この場合は描画前のスクリプトが不要になり、ちらつきも原理的に起きません(公式ドキュメントもこの方法に触れています)。「クライアント保存ならスクリプト、サーバー保存なら初期クラスを出力」と使い分けると覚えてください。
18.5 実務: 色設計をダークモード前提にする
ダークモード対応で苦労しないコツは、第12章でも触れたとおり、最初からセマンティックトークンで色を組むことです。
dark: を要素ごとにベタ書きすると、bg-white dark:bg-gray-900 のような対が画面中に散らばり、後で色を調整するのが地獄になります。代わりに、第5章のテーマ変数を使い、.dark のときに変数の値だけを差し替える設計にします。
@theme {
--color-surface: oklch(1 0 0); /* ライトの背景 */
--color-foreground: oklch(0.2 0 0); /* ライトの文字 */
}
.dark {
--color-surface: oklch(0.2 0 0); /* ダークでは反転 */
--color-foreground: oklch(0.95 0 0);
}
こうしておけば、HTML は bg-surface text-foreground と書くだけで両モードに対応します。dark: を個別に書く量が激減し、配色の調整も CSS 変数の 1 か所で済みます。