Next.js ダークモード完全ガイド:ちらつきゼロの実装と Tailwind CSS 統合

最近では、ダークモードはもはや「あると嬉しい」機能ではなく、必須機能になりつつあります。夜更かしする開発者にとっても、目の健康を気にするユーザーにとっても、ダークモードは救世主です。
しかし、Next.js で完璧なダークモードを実装するのは意外と難しいものです。よくある落とし穴が「ちらつき(FOUC: Flash of Unstyled Content)」です。ページを開いた瞬間、一瞬だけ真っ白なライトテーマが表示され、すぐにダークテーマに切り替わる現象です。これは非常に目障りですし、SSR(サーバーサイドレンダリング)を採用している Next.js 特有の課題でもあります。
私自身、この問題に何度も悩まされました。localStorage を手動で読み込んだり、クラスを付け替えたりと試行錯誤しましたが、最終的にたどり着いたのが next-themes というライブラリです。これが、私の知る限り最もエレガントな解決策です。
この記事では、next-themes と Tailwind CSS を組み合わせて、プロフェッショナルな「ちらつきゼロ」のダークモードを実装する方法を解説します。
なぜ Next.js のダークモードは難しいのか?
SSR とハイドレーションの壁
Next.js はサーバーで HTML を生成(SSR)してブラウザに送ります。サーバーはユーザーのブラウザの設定(ライトかダークか)を知りません(Cookie送付などの例外を除いて)。そのため、サーバーはとりあえずデフォルト(例:ライトモード)で HTML を生成します。
ブラウザが HTML を受け取って表示した瞬間(これがライトモード)、JavaScript が起動し、localStorage やシステム設定を確認して「あ、ユーザーはダークモードだ」と気づき、急いでスタイルを切り替えます。この一瞬のタイムラグが「ちらつき」の原因です。
next-themes の魔法
next-themes はこの問題を賢い方法で解決します。ページの <head> 内に非常に軽量なスクリプトを注入し、React が起動する前、つまりブラウザがページを描画する直前に実行させます。このスクリプトが即座に適切なクラスを <html> タグに付与するため、ユーザーには最初から正しいテーマで表示されます。
実装ステップ 1: インストールと準備
まず必要なパッケージをインストールします。
npm install next-themes次に、Tailwind CSS に「クラスベースでダークモードを制御する」と伝えます。
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: [
// ... paths
],
darkMode: "class", // ここが重要! 'media' ではなく 'class' にする
theme: {
// ...
},
plugins: [],
};
export default config;darkMode: 'class' に設定しないと、システム設定にしか反応せず、トグルボタンでの切り替えができなくなります。
実装ステップ 2: Provider の作成
next-themes の ThemeProvider でアプリ全体をラップする必要がありますが、これはクライアントコンポーネントである必要があるため、専用のファイルに切り出します。
// components/theme-provider.tsx
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}そして、ルートレイアウト(app/layout.tsx)でこれを使用します。
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// suppressHydrationWarning は必須!これがないと警告が出る
<html lang="ja" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}重要なプロパティ解説
attribute="class": テーマが変わった時に<html>タグにclass="dark"を付けたり消したりします。これが Tailwind のdarkMode: 'class'と連動します。defaultTheme="system": 初回訪問時にシステムの配色設定に従います。enableSystem: システム設定の変更を監視します。disableTransitionOnChange: テーマ切り替え時に CSS トランジションを一時的に無効にします。これがないと、切り替えの瞬間に色が「うにょーん」と変わる奇妙なエフェクトがかかることがあります。suppressHydrationWarning:<html>タグに追加します。サーバー(属性なし)とクライアント(class="dark"がつく可能性あり)で HTML が異なるため、React が警告を出しますが、これは意図的なものなので無視してOKです。
実装ステップ 3: トグルボタンの作成
テーマを切り替えるボタンを作ります。ここで注意が必要なのが「ハイドレーション不一致」です。
// components/theme-toggle.tsx
"use client";
import { useState, useEffect } from "react";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react"; // アイコンライブラリ(何でもOK)
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// マウント完了まで待つ
useEffect(() => {
setMounted(true);
}, []);
// マウント前は何も表示しない(またはスケルトンを表示)
// これをしないと、サーバー(ボタン不明)とクライアント(Sun/Moon)で不一致が起きる
if (!mounted) {
return <div className="w-10 h-10" />; // プレースホルダー
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-5 w-5 text-gray-800 dark:text-gray-200" />
) : (
<Moon className="h-5 w-5 text-gray-800 dark:text-gray-200" />
)}
</button>
);
}mounted チェックを行わないと、以下のようなエラーがコンソールに出ます:Warning: Text content did not match. Server: "" Client: "Moon"
サーバーでは現在のテーマが分からないので、クライアント側で確実にテーマが確定してからアイコンを表示するのが鉄則です。
スタイリングの適用
設定が終われば、あとは Tailwind の dark: プレフィックスを使うだけです。
<div className="bg-white dark:bg-gray-900 text-black dark:text-gray-100">
<h1 className="text-2xl font-bold dark:text-white">こんにちは</h1>
<p className="text-gray-600 dark:text-gray-400">
ダークモード対応コンポーネントです。
</p>
</div>とても直感的ですね。
CSS 変数を使った高度な設定
毎回 dark: を書くのが面倒な場合や、デザインシステムを構築したい場合は、CSS 変数を使用することをお勧めします。shadcn/ui などは標準でこの方法を採用しています。
app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* 他のライトモード用変数 */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* 他のダークモード用変数 */
}
}
body {
@apply bg-background text-foreground;
}tailwind.config.ts:
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
},
},
},こう設定しておけば、コンポーネント側では単に bg-background text-foreground と書くだけで、モードに応じて自動的に色が変わります。dark: を大量に書く必要がなくなり、コードがスッキリします。
よくある落とし穴と解決策
1. 強制ハイドレーション警告
<html> タグへの suppressHydrationWarning 属性の付与を忘れると、コンソールに警告が出続けます。これは next-themes が <html> の属性を操作するため避けられない警告なので、抑制して問題ありません。
2. コンテンツのちらつき
まだ画面が一瞬白くなる場合は、ThemeProvider を body の中ではなく、ちゃんと html タグの内側、かつコンテンツよりも外側に配置しているか確認してください。また、カスタム CSS で transition: background-color をすべての要素に適用していると、初期ロード時に色が遷移して見えることがあります。disableTransitionOnChange プロパティが true になっているか再確認しましょう。
3. dev ツールでのテーマ変更
ブラウザの開発者ツールで「レンダリング」タブから prefers-color-scheme を切り替えてテストすることがよくあります。しかし、next-themes で一度手動でテーマ(Light/Dark)を選択すると、 localStorage にその選択が保存され、システム設定の変更が無視されるようになります。
システム設定連動に戻したい場合は、localStorage.removeItem('theme') を実行するか、アプリケーション側で「System」モードを選択するボタンを用意する必要があります。
まとめ
Next.js におけるダークモード実装は、next-themes と Tailwind CSS の組み合わせが最強のソリューションです。
- ちらつきなし:初期ロードスクリプトのおかげです。
- 実装が簡単:
ThemeProviderで囲むだけ。 - Tailwind 完全対応:
dark:ユーティリティがそのまま使えます。 - システム設定追従:OS の設定変更を検知して自動で切り替わります。
一度この快適さを知ると、もう独自実装には戻れません。ぜひあなたのプロジェクトにも導入して、ユーザー体験を一段階引き上げてください。
Next.js ダークモード実装手順
next-themes と Tailwind CSS を使用したちらつきのないダークモードの実装フロー
⏱️ Estimated time: 1 hr
- 1
Step1: パッケージのインストール
npm install next-themes を実行し、必要なライブラリをインストールします。 - 2
Step2: Tailwind 設定の変更
tailwind.config.ts を開き、darkMode: 'class' を追加します。これにより、クラスの付け替えでテーマを制御できるようになります。 - 3
Step3: ThemeProvider コンポーネントの作成
`components/theme-provider.tsx` を作成し、`next-themes` の Provider をラップしたクライアントコンポーネントを作成します。 - 4
Step4: ルートレイアウトへの適用
`app/layout.tsx` で `<html>` タグに `suppressHydrationWarning` を追加し、`<body>` 内を `ThemeProvider` でラップします。`attribute='class'` の設定を忘れずに。 - 5
Step5: トグルボタンの実装
`useTheme` フックを使用してテーマ切り替えボタンを作成します。`mounted` ステートを使用して、クライアントサイドでのレンダリングが完了してからアイコンを表示するようにし、ハイドレーションエラーを防ぎます。
FAQ
なぜ html タグに suppressHydrationWarning が必要なのですか?
ページ遷移時に色がアニメーションしてしまいます。無効にできますか?
dark: クラスを使わずに CSS 変数で実装できますか?
システム設定(OSのテーマ)に追従させるには?
トグルボタンのアイコンが表示されない、またはエラーになります。
3 min read · 公開日: 2025年12月20日 · 更新日: 2026年1月22日
関連記事
Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践
Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド


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