Next.js で next-themes と Tailwind CSS を使って Light/Dark モードを切り替える

フロントエンドエンジニアの上垣です。

この記事では、Next.js で、next-themes と Tailwind CSS を使って Light/Dark モード切り替え対応する例を紹介しています。

github.com

前提

  • この記事では下記のバージョンのライブラリを利用しています。

    • Next.js 13.4.12
    • next-themes 0.2.1
    • Tailwind CSS 3.3.2
  • Next.js の AppRouter を利用しています。

  • この記事で紹介するのは、「Light/Dark モードの切り替え」であり、Dark モード実装の詳細には触れていません。
  • サンプルアプリケーションは CodeSandbox で確認できます。

Tailwind CSS のダークモード

まず、Tailwind CSS のダークモード について簡単にまとめてみます。

Light/Dark モードの判定

Tailwindは、デフォルトではユーザーのシステムの prefers-color-scheme メディア特性の値を参照して、Light/Dark を判定します。prefers-color-scheme のみを参照する場合は、追加で特に何も設定する必要はないです。

一方、ユーザーの手動で Light/Dark モードを切り替えたい場合は、tailwind.config.js に、下記を設定します。

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class',
  // ...
}

これにより、 dark class が適用されている要素以下の要素に、Tailwind の dark モードのスタイルを適用できます。

Dark モードのスタイル

dark モードのスタイルは基本的に、既存のクラス名に dark: prefix をつければ OK です。

<html class="dark dark:bg-black dark:text-white"> // dark モード用の class を定義
  <body>      
    <h1>Headline</h1>       
  </body>
</html>

next-themes

Next.js アプリケーションで Tailwind CSS の Light/Dark モード切り替えを組み込む場合、リクエスト毎に以下を実行する必要があります。

  • 現在のモードを判定する
  • HTML に現在のモードを反映する
  • ユーザーが選択したモードを永続化(e.g. localStorage)する。

この一連の処理をうまく隠蔽して解決してくれるのが、next-themes です。

インストール

npm install next-themes

セットアップ

まず、next-themes が提供する ThemeProvider をセットアップします。

// app/providers.tsx
"use client";

import { FC, PropsWithChildren } from "react";
import { ThemeProvider } from "next-themes";

export const Providers: FC<PropsWithChildren> = ({ children }) => {
  return <ThemeProvider attribute="class">{children}</ThemeProvider>;
};

ThemeProvider は、システムの prefers-color-scheme や、ブラウザの localStorage へアクセスするため、クライアントサイドでの実行が必要です。そのため、use client ディレクティブを付与した app/providers.tsx でラップしています。

また、TemeProvider に, attribute=”class” を指定しています。これは前述したように、 Tailwind CSS では className で Light/Dark を判定する必要があるので、Tailwind と組み合わせる場合は必須です。


次に、app/providers.tsxlayout.tsx で読み込みます。

// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html suppressHydrationWarning>
      <body className="min-h-screen dark:bg-black dark:text-white">
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

html 要素に suppressHydrationWarning を指定しているのは、next-themes がクライアントサイドで html 要素の className を書き換えることで発生する、ハイドレーション不一致の警告を抑制するためです。(参考)

なお、この警告抑制は1階層のみで機能するので、他の要素では警告を抑制しません。(ただ、suppressHydrationWarning が理想的な解決策ではないことは、Cookie の利用も含めて issue でも議論されており、今後改善される可能性もあります。)

実行結果

この状態でアプリケーションを起動すると、下記が確認できます。

  • クライアントサイドの html 要素に、現在のモードが付与される

  • ローカルストレージに、現在のモードが永続化される

  • body タグの直下に、現在のモードを判定する 即時実行 function が挿入される

Light/Dark モードの切り替え

最後に、Light/Dark モードを切り替える select を作成します。

これも同様にクライアントサイドでの実行が必要なため、use client ディレクティブを付与しています。サーバーサイドでは現在の theme を読み取れないため、クライアントでの mount を待ってから select を描画することで、ハイドレーションのミスマッチを抑制しています。

// themeSwitch.tsx
"use client";

import { useState, useEffect } from "react";
import { useTheme } from "next-themes";

export const ThemeSwitch = () => {
  const [mounted, setMounted] = useState(false);
  const { theme, setTheme } = useTheme();

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return null;
  }

  return (
    <select
      value={theme}
      onChange={(e) => setTheme(e.target.value)}
      className="border-2 border-black dark:border-white"
    >
      <option value="system">System</option>
      <option value="dark">Dark</option>
      <option value="light">Light</option>
    </select>
  );
};

サンプルアプリケーション

以上を実装したアプリケーションはこちらです。

モードを選択してからリロードしても、画面のチラつきなしで そのモードが適用されるのが確認できます。

感想

next-themes と Tailwind CSS で、ダークモード対応する例を紹介しました。

Light/Dark 切り替えを自前で実装するにしても、AppRouter では サーバー/クライアントの切り分けなど考慮することが多いので、next-themes のようなライブラリの存在は非常にありがたいです。

Tailwind CSS のダークモード実装詳細は、基本的にしっかりコンポーネント分割されていればそこまで大変ではないかなと思いつつ、より良いスタイルの管理方法もある気がするので、色々試しながら理解を深めていきたいと思います。