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 メディアクエリを使い、ユーザーのシステムのダークモード設定を自動検出します。
<!-- 設定不要、システム設定に自動で反応する -->
<div class="bg-white dark:bg-gray-900">
コンテンツはシステム設定に合わせて自動的に切り替わる
</div>
メリットは明確です。ゼロ設定で、ユーザーが何もしなくても好みに合った表示が得られます。ただし、デメリットも痛いところです。ユーザー自身に選ばせることができません。ライトな環境でダークモードを使いたい人にとっては、体験が今ひとつになってしまいます。
Class 戦略:手動で切り替えを制御
Class 戦略は、親要素(通常は <html>)に .dark クラスを付けてダークモードを発動させます。これで開発者が十分な制御権を持ち、ユーザーによる手動切り替えや設定の永続化も実現できます。
<!-- JavaScript でクラス名を制御する -->
<html class="dark">
<body class="bg-white dark:bg-gray-900">
ダークモードが有効
</body>
</html>
現在もっともよく使われている方式です。コミュニティのドキュメントが豊富で、各種サードパーティライブラリとの統合もスムーズに進みます。
Data-theme 戦略:セマンティックな属性セレクタ
Data-theme 戦略は、クラス名ではなく data-theme="dark" 属性を使います。セマンティクスがより明確で、しかもマルチテーマ拡張を自然にサポートします。
<html data-theme="dark">
<body class="bg-white dark:bg-gray-900">
ダークモードが有効
</body>
</html>
より多くのテーマへの拡張がとても簡単です。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 がデフォルトのライトモードでレンダリングされてしまうからです。
対策は、<head> 内に同期実行のスクリプトを置き、DOM のレンダリング前にテーマを設定しておくことです:
<head>
<script>
// 同期実行で、ちらつきを防ぐ
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
</head>
このスクリプトは必ず同期実行にします。defer も async も使えません。
メリットとデメリット
メリット:
- 実装がシンプルで直感的、すぐに使い始められる
- コミュニティのリソースが多く、各種フレームワークに成熟した解決策がある
- 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 モードや目に優しいモードの定義も簡単です:
<html data-theme="oled">
<!-- 純黒の背景、OLED スクリーンに最適 -->
</html>
<html data-theme="sepia">
<!-- 淡い黄色の背景、読書に最適 -->
</html>
切り替えロジックは、属性値を変えるだけです:
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 戦略 |
|---|---|---|
| 実装の複雑さ | 低、設定がシンプル | 中、属性セレクタの理解が必要 |
| セマンティクスの明確さ | 中、.dark の意味は少し考える必要あり | 高、data-theme は直感的 |
| マルチテーマ拡張 | 困難、複数のクラス名が必要 | 容易、属性値を変えるだけ |
| コミュニティサポート | 高、ドキュメントが豊富 | 中、普及が進行中 |
| CSS 変数統合 | 追加の対応が必要 | 自然に相性がよい |
| Tailwind v3 | darkMode: '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()]
}
});
ダークモードスクリプト:
<!-- BaseLayout.astro の head に配置する -->
<script is:inline>
// 白画面のちらつきを防ぐ同期スクリプト
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';
}
</script>
View Transitions の処理:
Astro の View Transitions はページ遷移時に DOM を再レンダリングするため、ダークモードの状態が失われやすくなります。astro:after-swap イベントを監視してテーマを再設定する必要があります:
<script>
document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});
</script>
この手順はかなり重要です。多くの開発者が見落としがちで、私もこの落とし穴を踏みました。
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 (
<ThemeProvider
attribute="class" // class 戦略を使う
defaultTheme="system" // デフォルトでシステムに追従
enableSystem={true} // システム検出を有効化
disableTransitionOnChange // 切り替え時のちらつきを防ぐ
>
{children}
</ThemeProvider>
);
}
data-theme 戦略に切り替えたい?attribute プロパティを変えるだけです:
<ThemeProvider attribute="data-theme" defaultTheme="system">
layout での利用:
// app/layout.tsx
import { ThemeProvider } from './components/ThemeProvider';
export default function RootLayout({ children }) {
return (
<html lang="ja">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
切り替えボタンコンポーネント:
// components/ThemeToggle.tsx
import { useTheme } from 'next-themes';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
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);
}
そして、これらの色を直接使います:
<button class="bg-primary text-white">ボタン</button>
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';
}
});
こうすれば、ユーザーは固定テーマを選ぶことも、常にシステムに追従させることもできます。
ベストプラクティスのまとめ
推奨する方式の選び方
ほとんどのプロジェクトに対する私のアドバイスはこうです:
- シンプルなプロジェクト:class 戦略を使い、簡単な切り替えスクリプトを添えれば十分
- shadcn/ui を使う場合:そのまま data-theme + CSS 変数の方式へ
- マルチテーマが必要な場合:data-theme 戦略が必須
- Next.js プロジェクト:next-themes を使い、attribute は要件に応じて選ぶ
- Astro プロジェクト:View Transitions の処理にくれぐれも注意する
実践テクニック
白画面のちらつきを防ぐ完全な方法:
<head>
<script is:inline>
// 同期スクリプト、レンダリング前に実行する
(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';
}
})();
</script>
</head>
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 といった落とし穴をきちんと処理することです。こうした細部をおろそかにすると、体験が損なわれてしまいます。
参考資料
- Tailwind CSS Dark Mode 公式ドキュメント
- shadcn/ui Theming ドキュメント
- next-themes GitHub
- Astro Dark Mode with Tailwind
FAQ
Tailwind v4 の @custom-variant と v3 の設定はどう違いますか?
class と data-theme を同時に使えますか?
dark: 修飾子が多すぎてコードが冗長です。どうすればいいですか?
具体的な手順:
1. globals.css で @theme を使って変数を定義する
2. それぞれの [data-theme] 下で変数値を上書きする
3. tailwind.config.js でこれらの変数を参照する
こうすれば bg-primary がテーマ切り替えに自動で対応します。
Astro プロジェクトでダークモードの状態が失われるときはどうしますか?
document.addEventListener('astro:after-swap', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});
この手順は多くの開発者が見落としがちです。
ページ読み込み時の白画面のちらつきはどう解決しますか?
<script>
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
注意:スクリプトは必ず同期実行にし、defer や async は使わないでください。
5分で読めます · 公開日: 2026年3月28日 · 更新日: 2026年6月8日
Tailwind と shadcn/ui 実践ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Tailwind レスポンシブレイアウト実践:コンテナクエリとブレークポイント戦略
Tailwind CSS のコンテナクエリとブレークポイント戦略を詳しく解説。ビューポートからコンテナへのレスポンシブ設計の進化を理解し、コンポーネント単位のレスポンシブレイアウトを実現しましょう。
第 4 / 11 記事
次の記事
shadcn/ui コンポーネント組み合わせパターン:実践ベストプラクティス
shadcn/ui コンポーネント組み合わせのベストプラクティスを学ぶ。Dialog+Form、DataTable+DropdownMenu パターン、Context 状態管理、パフォーマンス最適化を実践例とともに解説
第 6 / 11 記事
関連記事
Tailwind v4 + Vite:5 分で完成する設定テンプレートとディレクトリ構成
Tailwind v4 + Vite:5 分で完成する設定テンプレートとディレクトリ構成
shadcn/ui のインストールとテーマカスタマイズ完全ガイド(CSS 変数つき)
shadcn/ui のインストールとテーマカスタマイズ完全ガイド(CSS 変数つき)
shadcn/ui で管理画面の骨組みを構築:Sidebar + Layout ベストプラクティス
コメント
GitHubアカウントでログインしてコメントできます