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

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

最近では、ダークモードはもはや「あると嬉しい」機能ではなく、必須機能になりつつあります。夜更かしする開発者にとっても、目の健康を気にするユーザーにとっても、ダークモードは救世主です。

しかし、Next.js で完璧なダークモードを実装するのは意外と難しいものです。よくある落とし穴が「ちらつき(FOUC: Flash of Unstyled Content)」です。ページを開いた瞬間、一瞬だけ真っ白なライトテーマが表示され、すぐにダークテーマに切り替わる現象です。これは非常に目障りですし、SSR(サーバーサイドレンダリング)を採用している Next.js 特有の課題でもあります。

私自身、この問題に何度も悩まされました。localStorage を手動で読み込んだり、クラスを付け替えたりと試行錯誤しましたが、最終的にたどり着いたのが next-themes というライブラリです。これが、私の知る限り最もエレガントな解決策です。

この記事では、next-themesTailwind 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-themesThemeProvider でアプリ全体をラップする必要がありますが、これはクライアントコンポーネントである必要があるため、専用のファイルに切り出します。

// 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. コンテンツのちらつき

まだ画面が一瞬白くなる場合は、ThemeProviderbody の中ではなく、ちゃんと 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 の組み合わせが最強のソリューションです。

  1. ちらつきなし:初期ロードスクリプトのおかげです。
  2. 実装が簡単ThemeProvider で囲むだけ。
  3. Tailwind 完全対応dark: ユーティリティがそのまま使えます。
  4. システム設定追従:OS の設定変更を検知して自動で切り替わります。

一度この快適さを知ると、もう独自実装には戻れません。ぜひあなたのプロジェクトにも導入して、ユーザー体験を一段階引き上げてください。

Next.js ダークモード実装手順

next-themes と Tailwind CSS を使用したちらつきのないダークモードの実装フロー

⏱️ Estimated time: 1 hr

  1. 1

    Step1: パッケージのインストール

    npm install next-themes を実行し、必要なライブラリをインストールします。
  2. 2

    Step2: Tailwind 設定の変更

    tailwind.config.ts を開き、darkMode: 'class' を追加します。これにより、クラスの付け替えでテーマを制御できるようになります。
  3. 3

    Step3: ThemeProvider コンポーネントの作成

    `components/theme-provider.tsx` を作成し、`next-themes` の Provider をラップしたクライアントコンポーネントを作成します。
  4. 4

    Step4: ルートレイアウトへの適用

    `app/layout.tsx` で `<html>` タグに `suppressHydrationWarning` を追加し、`<body>` 内を `ThemeProvider` でラップします。`attribute='class'` の設定を忘れずに。
  5. 5

    Step5: トグルボタンの実装

    `useTheme` フックを使用してテーマ切り替えボタンを作成します。`mounted` ステートを使用して、クライアントサイドでのレンダリングが完了してからアイコンを表示するようにし、ハイドレーションエラーを防ぎます。

FAQ

なぜ html タグに suppressHydrationWarning が必要なのですか?
next-themes はサーバーで生成された HTML とは異なる属性(class='dark'など)をクライアントサイドで即座に適用するため、React が「サーバーとクライアントの内容が一致しない」と警告を出します。この動作は意図的なものなので、警告を抑制するために属性を追加します。
ページ遷移時に色がアニメーションしてしまいます。無効にできますか?
はい、ThemeProvider の `disableTransitionOnChange` プロパティを `true` に設定してください。これにより、テーマ切り替え時の一時的な CSS トランジションが無効になり、スムーズに切り替わります。
dark: クラスを使わずに CSS 変数で実装できますか?
可能です。globals.css で :root と .dark クラスに対して CSS 変数(--background など)を定義し、tailwind.config.ts でそれらを参照するように設定すれば、dark: プレフィックスなしでテーマに応じた色が適用されます。
システム設定(OSのテーマ)に追従させるには?
ThemeProvider の `enableSystem` プロパティを `true`(デフォルト)にし、`defaultTheme` を `system` に設定します。トグルボタン等で手動変更していない限り、OS の設定変更に合わせて自動的に切り替わります。
トグルボタンのアイコンが表示されない、またはエラーになります。
useTheme フックはクライアントサイドでのみ動作するため、マウント前にテーマ情報にアクセスすると不一致が起きます。useEffect で mounted フラグを管理し、true になってからボタンやアイコンをレンダリングするようにしてください。

3 min read · 公開日: 2025年12月20日 · 更新日: 2026年1月22日

コメント

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

関連記事