Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第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 か所で済みます。

ライトとダークで同じ bg-surface text-foreground を使い、:root と .dark でトークン値だけが差し替わる図
図 18-1 ダークモード: 同じ class のまま、`:root` と `.dark` でトークン値を差し替えて色が変わる。

参考資料