🌃

Next.js + Tailwind CSS でダークモード実装

2024/03/24に公開

はじめに

先日Next.js + Tailwind CSSで構築している個人ブログでダークモードの実装を行いました。
ここではその手順を紹介しようと思います。

環境

パッケージ名 バージョン
next 14.0.4
tailwindcss 3.4.1
next-themes 0.3.0

※ Next.jsはApp Routerを使用(Pages Routerでも適用可能です)

Tailwind CSS でのダークモード用のスタイル設定

まずはダークモード用のスタイルを用意します。

https://tailwindcss.com/docs/dark-mode

Tailwind CSSではダークモードに対応したVariantが用意されており、次のようにdark:プレフィックスをつけるだけで簡単にスタイルを切り替えることができます。

<div class="bg-white dark:bg-slate-800">
  <p class="text-slate-900 dark:text-white">テキスト</p>
</div>

ユーザーのOS設定に応じて切り替えるだけ(メディアクエリ prefers-color-scheme で判定)なら、これだけで対応が完了してしまいます。

ただしこの場合だと、サイトを訪れたユーザーはOS設定を変更しない限り見た目を切り替えることができません。

今回はユーザーに対してモード切替用のUIを提供したかったため、html要素のクラスに応じてスタイルが切り替わるように設定を変更します。

tailwind.config.jsにdarkModeの設定を追加しましょう。

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
+  darkMode: ['selector', '.dark'],
  // ...
}

こうすることでprefers-color-schemeではなく、「DOMツリーの祖先にdarkクラスを持った要素があるか」を基準にスタイルを切り替えてくれるようになります。

任意のセレクタを基準にする

.darkではなく[data-mode="dark"]など、任意のセレクタを基準にすることも可能です。

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: ['selector', '[data-mode="dark"]'],
  // ...
}

next-themesの導入

next-themesを使うとNext.jsプロジェクトにおけるテーマの管理がとても簡単に行えるようになります。

詳しい説明はドキュメントを参照いただければと思いますが、

  • 選択中のテーマをLocalStorageへ保存して再訪問時にも設定値を保持
  • ページ読み込み時や更新時のチラつき防止
  • カスタムフックuseThemeの提供(テーマの取得や更新が手軽にできる)
  • CSSのcolor-schemeの切り替え

などをまとめて面倒見てくれるライブラリとなっています。

実際の使い勝手もよかったので、Next.jsでダークモードを実装する際にはとりあえずこちらを使っておけば間違いなさそうです。(shadcn/uiのドキュメントでも紹介されていました)

導入手順

まずはパッケージをインストールしましょう。

npm install next-themes

次に app/layout.tsx を更新していきます。

テーマを全体に適用するため、next-themesが提供するThemeProviderでbody直下の要素全体をラップ。さらにhtml要素に suppressHydrationWarning を追加します。

app/layout.tsx
+ import { ThemeProvider } from 'next-themes'

const RootLayout = ({ children }: { children: React.ReactNode }) => (
+  <html lang="ja" suppressHydrationWarning>
    <head />
    <body>
+      <ThemeProvider>{children}</ThemeProvider>
    </body>
  </html>
);

export default RootLayout;

suppressHydrationWarning について

next-themes はhtml要素を直接更新する(classやstyleを付与)ため、何もしないとReactのハイドレーション不一致の警告が出てしまいます。suppressHydrationWarning はこの警告を無視するための指定です。

なおこのプロパティは下層には影響しないため、プロジェクト内の他の場所で発生した警告を無視することはありません。

next-themesの設定をカスタマイズ

ThemeProviderに対していくつか設定を追加します。

  • テーマ切り替えにclassを利用する: attribute="class"
  • システム設定に応じた切り替えを有効化: enableSystem
  • デフォルトではシステム設定にしたがう: defaultTheme="system"
app/layout.tsx
...
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
    {children}
  </ThemeProvider>
...

切り替え用のUIを用意する

ここはプロジェクトによりさまざまかと思いますが、今回はTailwind CSSの公式サイトと同じくドロップダウンからLight Dark Systemを選択できるようにしました。

テーマ切り替え用のUIを示した画像:アイコン付きのボタンの下にドロップダウンメニューが表示されている。メニュー内にはLight,Dark,Systemの3つのテーマがアイコンと並んで表示されている
UIのイメージ

Radix UI の Dropdown MenuHero Iconsを利用して作成したコンポーネントの完成形を以下に記載します。

ColorThemeSelector.tsx
'use client';

import { ComputerDesktopIcon, MoonIcon, SunIcon } from '@heroicons/react/24/outline';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

const ColorThemeSelector = () => {
  const [mounted, setMounted] = useState(false);
  const { theme, resolvedTheme, themes, setTheme } = useTheme();

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

  if (!mounted) {
    return (
      <div className="rounded border p-2 dark:border-gray-500">
        <div className="size-6"></div>
      </div>
    );
  }

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button
          aria-label="カラーテーマを選択する"
          className="rounded border p-2 text-gray-700 dark:border-gray-500 dark:text-slate-300"
          type="button"
        >
          {resolvedTheme === 'light' ? (
            <SunIcon className="size-6" />
          ) : (
            <MoonIcon className="size-6" />
          )}
        </button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content
          align="end"
          className="overflow-hidden rounded border bg-white shadow-sm dark:border-gray-500 dark:bg-gray-950"
          sideOffset={8}
        >
          <DropdownMenu.Group className="flex flex-col">
            {themes.map((item) => (
              <DropdownMenu.Item
                className={`flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-slate-300 dark:hover:bg-gray-800 ${item === theme ? 'bg-gray-100 dark:bg-gray-800' : ''}`}
                key={item}
                onClick={() => setTheme(item)}
              >
                {item === 'light' ? (
                  <SunIcon className="size-5" />
                ) : item === 'system' ? (
                  <ComputerDesktopIcon className="size-5" />
                ) : (
                  <MoonIcon className="size-5" />
                )}
                <span className="capitalize">{item}</span>
                {item === theme && <span className="sr-only">(選択中)</span>}
              </DropdownMenu.Item>
            ))}
          </DropdownMenu.Group>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
};

export default ColorThemeSelector;

コンポーネントの解説

カスタムフック:useThemeについて

テーマの取得や更新用のAPIがカスタムフックuseThemeとして提供されています。

今回はtheme, resolvedTheme, themes, setThemeを利用しました。

  • theme:現在選択されているテーマ(今回の場合light dark systemのいずれか)
  • resolvedTheme:システム設定も踏まえた現在のテーマ(light darkのいずれか)
  • theme:テーマのリスト
  • setTheme:テーマを更新する関数

resolvedThemeを利用すると、システム設定が選択されていた場合でも現在表示中のテーマを取得することができます。
ここでは、メニューを開くボタンのアイコンをresolvedThemeの値に対応させています。

クライアントでマウントされるまではスケルトンを表示

サーバー上ではthemeを知ることができないため、useThemeから返される値の多くはクライアントにマウントされるまでundefinedとなります。そのためクライアントでのマウント前にthemeなどを使って UI をレンダリングしようとすると、hydration mismatch エラーが発生します。

それを回避するため、useEffectを利用して、クライアントにマウントされるまではスケルトンをレンダリングするようにしています。

const [mounted, setMounted] = useState(false);

// useEffectはクライアントでしか実行されないため、これで安全にUIを表示できる。
useEffect(() => {
  setMounted(true);
}, []);

if (!mounted) {
  return (
    <div className="rounded border p-2 dark:border-gray-500">
      <div className="size-6"></div>
    </div>
  );
}

https://github.com/pacocoursey/next-themes?tab=readme-ov-file#avoid-hydration-mismatch

まとめ

Tailwind CSSのdark Variantとnext-themesを利用することで、かなり手軽にダークモードを実装することができました。

next-themesresolvedThemeを提供してくれたり、CSSのcolor-schemeプロパティ[1]も切り替えてくれるなど細かいところまで配慮が行き届いている点も嬉しいなと感じました。

備考

  • 今回はTailwind CSSのdarkVariantを利用しましたが、CSS Variablesで動的に定義するといった方法も考えられます。こちらについては書籍『Tailwind CSS 実践入門』で詳しく触れられていました。
  • 実装時、UIについてあまり深く考えていませんでしたが、多くの場合トグルでLight/Darkを切り替える方がシンプルで良さそうだなと考え直しています。(理由↓)
    • System という選択肢が多くのユーザーには伝わらない気がする
    • 初期値をprefers-color-schemeから取得することでシステム設定には対応できる

参考URL

https://goodpatch-tech.hatenablog.com/entry/next-themes-tailwind
https://www.gaji.jp/blog/2023/11/14/17627/
https://azukiazusa.dev/blog/tailwindcss-dark-mode/
https://azukiazusa.dev/blog/tailwind-css-dark-mode-system-light-dark/

脚注
  1. Webページ側から対応しているカラースキーマを提示できるプロパティです。color-scheme: darkの場合、多くのブラウザではスクロールバーの配色などをダークモードにあわせて変更します。MDN ↩︎

GitHubで編集を提案

Discussion