言語を切り替える
テーマを切り替える

Tailwind ダークモード:class と data-theme の2つの方式を比較

画面上で点滅するあの dark:bg-gray-900 を前に、一つの疑問が浮かびます。Tailwind のダークモードは class と data-theme のどちらを使うべきなのか、と。

この2つの方式にはしばらく頭を悩ませました。ドキュメントを検索しても、見つかるのは断片的な説明ばかりで、つなぎ合わせてもどこか物足りない。そこで思い切って、公式ドキュメント、GitHub のディスカッション、人気コンポーネントライブラリのソースコードを一通り読み込み、ようやく考えが整理できました。この記事では、踏んできた落とし穴も、見えてきたトレードオフも、すべて包み隠さずお話しします。


Tailwind ダークモードの3つの戦略

まず一つはっきりさせておきましょう。Tailwind はデフォルトで3つのダークモード戦略を提供しています。2つだけではありません。

Media 戦略:システムに自動追従

Media 戦略は Tailwind のデフォルト設定です。正直なところ、これがデフォルトだと知らない人も多いかもしれません。prefers-color-scheme という CSS メディアクエリを使い、ユーザーのシステムのダークモード設定を自動検出します。

<!-- 設定不要、システム設定に自動で反応する -->
&lt;div class="bg-white dark:bg-gray-900"&gt;
  コンテンツはシステム設定に合わせて自動的に切り替わる
&lt;/div&gt;

メリットは明確です。ゼロ設定で、ユーザーが何もしなくても好みに合った表示が得られます。ただし、デメリットも痛いところです。ユーザー自身に選ばせることができません。ライトな環境でダークモードを使いたい人にとっては、体験が今ひとつになってしまいます。

Class 戦略:手動で切り替えを制御

Class 戦略は、親要素(通常は &lt;html&gt;)に .dark クラスを付けてダークモードを発動させます。これで開発者が十分な制御権を持ち、ユーザーによる手動切り替えや設定の永続化も実現できます。

<!-- JavaScript でクラス名を制御する -->
&lt;html class="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    ダークモードが有効
  &lt;/body&gt;
&lt;/html&gt;

現在もっともよく使われている方式です。コミュニティのドキュメントが豊富で、各種サードパーティライブラリとの統合もスムーズに進みます。

Data-theme 戦略:セマンティックな属性セレクタ

Data-theme 戦略は、クラス名ではなく data-theme="dark" 属性を使います。セマンティクスがより明確で、しかもマルチテーマ拡張を自然にサポートします。

&lt;html data-theme="dark"&gt;
  &lt;body class="bg-white dark:bg-gray-900"&gt;
    ダークモードが有効
  &lt;/body&gt;
&lt;/html&gt;

より多くのテーマへの拡張がとても簡単です。data-theme="oled" でも data-theme="sepia" でも、自由に定義できます。複数の表示モードをサポートしたい場面では、これが本当に便利です。


Class 戦略の詳細

実装原理

Class 戦略の中心となる原理は、実はかなりシンプルです。DOM ツリーのどこかの祖先要素に .dark クラスが存在すると、すべての dark:* 修飾子のスタイルが有効になります。

Tailwind v3 では、設定ファイルで有効化します:

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

生成される CSS セレクタの構造はこうなります:

.dark .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v4 では、まったく新しい CSS-first の設定方式に変わり、@custom-variant ディレクティブを使います:

/* global.css */
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));

注目すべきは :where() 擬似クラスです。これは specificity をゼロまで下げるので、他のスタイルの優先順位計算に干渉しません。この細部がかなり重要です。

JavaScript の切り替えロジック

ユーザーによる切り替えの実装は、ちょっとした JavaScript で十分です:

// 現在のテーマを取得する
function getTheme() {
  return localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}

// テーマを設定する
function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.classList.toggle('dark', theme === 'dark');
}

// 初期化
setTheme(getTheme());

このコードは3つのことをしています。localStorage からユーザーの設定を読み取り、設定がなければシステムに追従し、テーマを切り替えて保存する。これで十分です。

白画面のちらつきを防ぐ

ページ読み込み時の一瞬の白画面のちらつき、これも私が踏んだ落とし穴です。理由はシンプルで、JavaScript が実行される前に HTML がデフォルトのライトモードでレンダリングされてしまうからです。

対策は、&lt;head&gt; 内に同期実行のスクリプトを置き、DOM のレンダリング前にテーマを設定しておくことです:

&lt;head&gt;
  &lt;script&gt;
    // 同期実行で、ちらつきを防ぐ
    if (localStorage.theme === 'dark' ||
        (!('theme' in localStorage) &&
         window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  &lt;/script&gt;
&lt;/head&gt;

このスクリプトは必ず同期実行にします。deferasync も使えません。

メリットとデメリット

メリット

  • 実装がシンプルで直感的、すぐに使い始められる
  • コミュニティのリソースが多く、各種フレームワークに成熟した解決策がある
  • next-themes などのツールライブラリと相性がよい
  • specificity がやや高く、スタイルの上書きが保証される

デメリット

  • .dark というクラス名はセマンティクスが明確でない。コードを見て少し考えないとダークモードだと分からない
  • マルチテーマ拡張には複数のクラス名が必要で、管理が少し煩雑になる
  • CSS 変数方式と組み合わせるときには、追加の対応が必要になる

Data-theme 戦略の詳細

実装原理

Data-theme 戦略の中心は、クラスセレクタではなく属性セレクタを使う点です。Tailwind v4 では次のように設定します:

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

生成される CSS セレクタ:

[data-theme='dark'] .dark:bg-gray-900 {
  background-color: #111827;
}

Tailwind v3 でもサポートされていますが、配列での設定が必要です:

// tailwind.config.js
module.exports = {
  darkMode: ['selector', '[data-theme="dark"]'],
}

CSS 変数方式との組み合わせ

正直に言うと、data-theme 戦略と CSS 変数方式はまさに相性抜群のペアです。それぞれの data-theme 下で、異なる変数値を定義できます:

/* globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222 84% 5%;
}

[data-theme='dark'] {
  --background: 222 84% 5%;
  --foreground: 210 40% 98%;
}

[data-theme='oled'] {
  --background: 0 0% 0%;  /* 純黒 */
  --foreground: 0 0% 100%;
}

そして Tailwind の設定でこれらの変数を参照します:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
      }
    }
  }
}

こうすれば、data-theme 属性を切り替えるだけで、これらの変数を使ったすべてのスタイルが自動で切り替わります。各コンポーネントに dark: 修飾子を書く必要がありません。この体験は本当に快適です。

shadcn/ui の実践経験

shadcn/ui コンポーネントライブラリは、デフォルトでこの data-theme + CSS 変数という方式を採用しています。スタイルファイルをめくってみると、こうした定義が大量に見つかります:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    /* ... さらに多くの変数 */
  }

  .dark,
  [data-theme='dark'] {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... さらに多くの変数 */
  }
}

興味深いのは、.dark クラスと [data-theme='dark'] 属性の両方を同時にサポートしている点です。これは異なるユーザーの習慣に対応するためです。shadcn/ui を使う場合、どちらの方法でダークモードを発動させても問題ありません。

マルチテーマ拡張の能力

Data-theme 方式の最大の強みはここにあります。マルチテーマのサポートです。OLED モードや目に優しいモードの定義も簡単です:

&lt;html data-theme="oled"&gt;
  <!-- 純黒の背景、OLED スクリーンに最適 -->
&lt;/html&gt;

&lt;html data-theme="sepia"&gt;
  <!-- 淡い黄色の背景、読書に最適 -->
&lt;/html&gt;

切り替えロジックは、属性値を変えるだけです:

function setTheme(theme) {
  localStorage.setItem('theme', theme);
  document.documentElement.dataset.theme = theme;
}

この柔軟さは、class 戦略ではなかなか実現しづらいものです。

メリットとデメリット

メリット

  • セマンティクスが明確。data-theme="dark" なら一目でダークモードだと分かる
  • マルチテーマ拡張を自然にサポートする
  • CSS 変数方式との組み合わせが特にスムーズ
  • shadcn/ui や daisyUI といったライブラリがデフォルトで対応している

デメリット

  • Tailwind v3 では自分でセレクタを設定する必要がある
  • 一部のサードパーティライブラリは対応が必要なことがある
  • コミュニティのドキュメントが比較的少ない。ただしこの状況は改善されつつある

2つの方式の比較マトリックス

主要な観点をまとめた比較表を用意しました:

Class の実装の複雑さ
設定がシンプル
Data-theme の実装の複雑さ
属性セレクタの理解が必要
Class のコミュニティサポート
ドキュメントが豊富
Data-theme のコミュニティサポート
普及が進行中
困難
Class のマルチテーマ拡張
複数のクラス名が必要
容易
Data-theme のマルチテーマ拡張
属性値を変えるだけ
Source: 方式の比較分析
比較の観点Class 戦略Data-theme 戦略
実装の複雑さ低、設定がシンプル中、属性セレクタの理解が必要
セマンティクスの明確さ中、.dark の意味は少し考える必要あり高、data-theme は直感的
マルチテーマ拡張困難、複数のクラス名が必要容易、属性値を変えるだけ
コミュニティサポート高、ドキュメントが豊富中、普及が進行中
CSS 変数統合追加の対応が必要自然に相性がよい
Tailwind v3darkMode: 'class'darkMode: ['selector', '...']
Tailwind v4@custom-variant@custom-variant
サードパーティライブラリ互換互換性の確認が必要shadcn/ui などが自然に対応
Specificityやや高い(クラスセレクタ)同じ(属性セレクタ)

Class 戦略を選ぶのはどんなとき?

  • プロジェクトがシンプルで、ライト/ダークの2モードだけで十分なとき
  • Next.js + next-themes の組み合わせを使うとき
  • チームが Tailwind v3 の設定に慣れているとき
  • コミュニティの事例を数多く参照する必要があるとき

Data-theme 戦略を選ぶのはどんなとき?

  • 複数のテーマ(OLED や目に優しいモードなど)をサポートしたいとき
  • shadcn/ui や類似のコンポーネントライブラリを使うとき
  • CSS 変数方式と深く組み合わせたいとき
  • プロジェクトのセマンティクス要件が高いとき

フレームワーク統合の実践

Astro での統合方法

Astro と Tailwind の統合自体はシンプルですが、一つ落とし穴があります。View Transitions の処理です。

基本設定

// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()]
  }
});

ダークモードスクリプト

&lt;!-- BaseLayout.astro の head に配置する --&gt;
&lt;script is:inline&gt;
  // 白画面のちらつきを防ぐ同期スクリプト
  const theme = localStorage.getItem('theme') ||
    (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');

  if (theme === 'dark') {
    document.documentElement.classList.add('dark');
    // または data-theme を使う
    // document.documentElement.dataset.theme = 'dark';
  }
&lt;/script&gt;

View Transitions の処理

Astro の View Transitions はページ遷移時に DOM を再レンダリングするため、ダークモードの状態が失われやすくなります。astro:after-swap イベントを監視してテーマを再設定する必要があります:

&lt;script&gt;
  document.addEventListener('astro:after-swap', () => {
    const theme = localStorage.getItem('theme');
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }
  });
&lt;/script&gt;

この手順はかなり重要です。多くの開発者が見落としがちで、私もこの落とし穴を踏みました。

Next.js + next-themes の統合

Next.js プロジェクトでは next-themes ライブラリの利用をおすすめします。テーマ切り替えのロジック一式が丸ごとパッケージ化されていて、SSR への対応や hydration の処理も気にする必要がありません。

インストール

npm install next-themes

Provider の設定

// components/ThemeProvider.tsx
import { ThemeProvider } from 'next-themes';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    &lt;ThemeProvider
      attribute="class"        // class 戦略を使う
      defaultTheme="system"    // デフォルトでシステムに追従
      enableSystem={true}      // システム検出を有効化
      disableTransitionOnChange  // 切り替え時のちらつきを防ぐ
    &gt;
      {children}
    &lt;/ThemeProvider&gt;
  );
}

data-theme 戦略に切り替えたい?attribute プロパティを変えるだけです:

&lt;ThemeProvider attribute="data-theme" defaultTheme="system"&gt;

layout での利用

// app/layout.tsx
import { ThemeProvider } from './components/ThemeProvider';

export default function RootLayout({ children }) {
  return (
    &lt;html lang="ja"&gt;
      &lt;body&gt;
        &lt;ThemeProvider&gt;
          {children}
        &lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}

切り替えボタンコンポーネント

// components/ThemeToggle.tsx
import { useTheme } from 'next-themes';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    &lt;button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-lg"
    &gt;
      {theme === 'dark' ? '☀️' : '🌙'}
    &lt;/button&gt;
  );
}

next-themes は localStorage への永続化、システム設定の検出、hydration 問題を自動で処理してくれます。手間が省けます。


Tailwind v4 の新機能

Tailwind v4 はまったく新しい CSS-first の設定方式をもたらし、ダークモードの設定も変わりました。

@custom-variant ディレクティブ

以前は JavaScript 設定ファイルで定義していた variant を、今では CSS 内で直接宣言できます:

@import 'tailwindcss';

/* Class 戦略 */
@custom-variant dark (&:where(.dark, .dark *));

/* Data-theme 戦略 */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

メリットはより直感的になったことです。設定を変えるのに JavaScript を再ビルドする必要がありません。

@theme ディレクティブで変数を定義

data-theme 戦略と組み合わせ、@theme ディレクティブでテーマ変数を定義します:

@import 'tailwindcss';
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

@theme {
  --color-primary: oklch(0.65 0.2 150);
  --color-muted: oklch(0.9 0.02 200);
}

/* ダークモード下の変数の上書き */
[data-theme='dark'] {
  --color-primary: oklch(0.7 0.15 180);
  --color-muted: oklch(0.3 0.02 200);
}

そして、これらの色を直接使います:

&lt;button class="bg-primary text-white"&gt;ボタン&lt;/button&gt;

data-theme を切り替えると色が自動で変わるので、dark:bg-primary-dark のような冗長なスタイルを書く必要がありません。

3状態の切り替えを実装する

light/dark/system の3状態の切り替えは、window.matchMedia API と組み合わせる必要があります:

function setTheme(theme) {
  if (theme === 'system') {
    localStorage.removeItem('theme');
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
  } else {
    localStorage.setItem('theme', theme);
    document.documentElement.dataset.theme = theme;
  }
}

// システム設定の変更を監視する
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
    }
  });

こうすれば、ユーザーは固定テーマを選ぶことも、常にシステムに追従させることもできます。


ベストプラクティスのまとめ

推奨する方式の選び方

ほとんどのプロジェクトに対する私のアドバイスはこうです:

  1. シンプルなプロジェクト:class 戦略を使い、簡単な切り替えスクリプトを添えれば十分
  2. shadcn/ui を使う場合:そのまま data-theme + CSS 変数の方式へ
  3. マルチテーマが必要な場合:data-theme 戦略が必須
  4. Next.js プロジェクト:next-themes を使い、attribute は要件に応じて選ぶ
  5. Astro プロジェクト:View Transitions の処理にくれぐれも注意する

実践テクニック

白画面のちらつきを防ぐ完全な方法

&lt;head&gt;
  &lt;script is:inline&gt;
    // 同期スクリプト、レンダリング前に実行する
    (function() {
      const theme = localStorage.getItem('theme');
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

      if (theme === 'dark' || (!theme && systemDark)) {
        document.documentElement.classList.add('dark');
        // または
        document.documentElement.dataset.theme = 'dark';
      }
    })();
  &lt;/script&gt;
&lt;/head&gt;

SSR プロジェクトの扱い方

Next.js のような SSR プロジェクトでは hydration mismatch を避ける必要があります。next-themes はすでにこの問題を処理してくれます。ただし自分で実装したい場合は、次の点に注意してください:

// useEffect で SSR の不整合を避ける
import { useEffect, useState } from 'react';

function useTheme() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    setTheme(saved || 'light');
  }, []);

  return theme;
}

CSS 変数のセマンティックな命名

色名ではなく、セマンティックな変数名を使いましょう:

/* 推奨 */
:root {
  --background: ...;
  --foreground: ...;
  --primary: ...;
  --muted: ...;
}

/* 非推奨 */
:root {
  --white: ...;
  --black: ...;
  --gray-900: ...;
}

セマンティックな命名にすると、テーマを切り替えるときに分かりやすくなり、後から新しいテーマを追加するのも楽になります。


まとめ

いろいろ話してきましたが、突き詰めると一言です。class 戦略はシンプルで成熟しており、ほとんどのプロジェクトに向いています。data-theme 戦略はセマンティクスが明確で、マルチテーマの場面や CSS 変数との深い組み合わせにより適しています。

Tailwind v4 の @custom-variant ディレクティブは、どちらの方式の設定もシンプルで直感的にしてくれました。どちらを選ぶかは、結局のところ自分の要件次第です。shadcn/ui を使うなら data-theme 方式がより自然ですし、シンプルなダークモード切り替えだけでよいなら class 戦略は今でも頼れる選択肢です。

見落としてはいけない細部が一つあります。フレームワーク統合時に、Astro の View Transitions や Next.js の SSR hydration といった落とし穴をきちんと処理することです。こうした細部をおろそかにすると、体験が損なわれてしまいます。



参考資料

FAQ

Tailwind v4 の @custom-variant と v3 の設定はどう違いますか?
主な違いは設定する場所です。v3 は JS 設定ファイル(tailwind.config.js)で定義し、v4 は CSS ファイル内で @custom-variant ディレクティブを使って宣言します。機能はまったく同じで、v4 の方式は「CSS-first」という設計思想により適合しています。
class と data-theme を同時に使えますか?
使えますが、その必要はありません。両者は機能的に完全に同じで、同時に使うとかえって複雑さが増します。shadcn/ui が .dark クラスと [data-theme="dark"] 属性の両方をサポートしているのは、ユーザーごとの好みの違いに対応するためです。どちらか一方を選んで使えば十分です。
dark: 修飾子が多すぎてコードが冗長です。どうすればいいですか?
CSS 変数方式を使いましょう。変数を定義しておけば、属性値を切り替えるだけで変数を使った全スタイルが自動的に更新され、各要素に dark: 修飾子を書く必要がなくなります。

具体的な手順:
1. globals.css で @theme を使って変数を定義する
2. それぞれの [data-theme] 下で変数値を上書きする
3. tailwind.config.js でこれらの変数を参照する

こうすれば bg-primary がテーマ切り替えに自動で対応します。
Astro プロジェクトでダークモードの状態が失われるときはどうしますか?
Astro の View Transitions はページ遷移時に DOM を再レンダリングするため、ダークモードの状態が失われます。解決方法は astro:after-swap イベントを監視してテーマを再設定することです:

document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});

この手順は多くの開発者が見落としがちです。
ページ読み込み時の白画面のちらつきはどう解決しますか?
&lt;head&gt; 内に同期実行のスクリプトを置き、DOM のレンダリング前にテーマを設定します:

&lt;script&gt;
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
&lt;/script&gt;

注意:スクリプトは必ず同期実行にし、defer や async は使わないでください。

5分で読めます · 公開日: 2026年3月28日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます