Next.js × Tailwind CSS(Flowbite) でダークモード機能を実装する

公開日:

目次

Next.js × Tailwind CSS(Flowbite 利用)でダークモード機能を実装したので、実装方法を紹介します。

完成形は、このブログのヘッダーに切り替え用のドロップダウンから確認できます。

前提

利用している技術は以下を想定しています。

  • Next.js Ver. 14.0.4
  • Tailwind CSS Ver. 3.4.16
  • Flowbite Ver. 2.2.0
  • React Ver. 18.2.0

結論

一部説明のため改変はしていますが、最終的には次のコードで実装することができました。

// app/components/ThemeColorSwitcher/ThemeColorSwitcher.tsx

'use client';

import { useBrowser } from '@/app/hooks';
import { isDarkMode, useColorThemeStore } from '@/app/stores/color-theme';
import clsx from 'clsx';
import { initDropdowns } from 'flowbite';
import React, { useEffect, useId, useMemo } from 'react';

const colorThemes = [
  {
    name: 'light',
    icon: (
      <svg
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="24"
        fill="none"
        viewBox="0 0 24 24"
      >
        <path
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth="2"
          d="M12 5V3m0 18v-2M7.05 7.05 5.636 5.636m12.728 12.728L16.95 16.95M5 12H3m18 0h-2M7.05 16.95l-1.414 1.414M18.364 5.636 16.95 7.05M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
        />
      </svg>
    ),
  },
  {
    name: 'dark',
    icon: (
      <svg
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="24"
        fill="none"
        viewBox="0 0 24 24"
      >
        <path
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth="2"
          d="M12 21a9 9 0 0 1-.5-17.986V3c-.354.966-.5 1.911-.5 3a9 9 0 0 0 9 9c.239 0 .254.018.488 0A9.004 9.004 0 0 1 12 21Z"
        />
      </svg>
    ),
  },
  {
    name: 'system',
    icon: (
      <svg
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="24"
        fill="none"
        viewBox="0 0 24 24"
      >
        <path
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth="2"
          d="M12 15v5m-3 0h6M4 11h16M5 15h14a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1Z"
        />
      </svg>
    ),
  },
];

function ThemeColorSwitcher() {
  const id = useId();
  const isBrowser = useBrowser();

  useEffect(() => {
    if (isBrowser) {
      initDropdowns();
    }
  }, [isBrowser]);

  const { colorTheme, changeColorTheme, removeColorTheme } =
    useColorThemeStore();

  const colorThemeName = useMemo(() => colorTheme ?? 'system', [colorTheme]);

  function handleClick(colorTheme: 'light' | 'dark' | 'system') {
    if (colorTheme === 'system') {
      removeColorTheme();
    } else {
      changeColorTheme(colorTheme);
    }
    // @ts-ignore
    const dropdown = FlowbiteInstances.getInstance(
      'Dropdown',
      `dropdown-${id}`,
    );
    dropdown.hide();
  }

  function renderIcon(colorTheme?: 'light' | 'dark') {
    if (isBrowser && isDarkMode(colorTheme)) {
      return colorThemes.find((theme) => theme.name === 'dark')?.icon;
    }

    return colorThemes.find((theme) => theme.name === 'light')?.icon;
  }

  if (!isBrowser) {
    return null;
  }

  return (
    <>
      <button
        id={id}
        data-dropdown-toggle={`dropdown-${id}`}
        className={clsx(
          'inline-flex h-10 w-10 items-center justify-center rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:hover:bg-gray-700 dark:focus:ring-gray-700',
          {
            'text-primary': isBrowser && colorTheme !== undefined,
            'text-gray-800 dark:text-white':
              isBrowser && colorTheme === undefined,
          },
        )}
        type="button"
      >
        {renderIcon(colorTheme)}
      </button>
      <div
        id={`dropdown-${id}`}
        className="z-10 hidden w-36 divide-y divide-gray-100 rounded-lg bg-white shadow dark:bg-gray-700"
      >
        <ul
          className="py-2 text-sm text-gray-700 dark:text-gray-200"
          aria-labelledby="themeColorSwitcher"
        >
          {colorThemes.map((theme) => (
            <li
              key={theme.name}
              className={clsx('group', {
                'is-selected': theme.name === colorThemeName,
              })}
            >
              <button
                type="button"
                className="block w-full px-4 py-2 text-gray-800 hover:bg-gray-100 group-[.is-selected]:text-primary dark:text-white dark:hover:bg-gray-600"
                onClick={() =>
                  handleClick(theme.name as 'light' | 'dark' | 'system')
                }
              >
                <span className="flex items-center gap-x-2">
                  <span>{theme.icon}</span>
                  <span className="font-bold capitalize">{theme.name}</span>
                </span>
              </button>
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

export default ThemeColorSwitcher;
app/hooks/useBrowser.tsx

import { useEffect, useState } from 'react';

export default function useBrowser() {
  const [isBrowser, setIsBrowser] = useState(false);
  useEffect(() => {
    setIsBrowser(true);
  }, [setIsBrowser]);

  return isBrowser;
}
app/stores/color-theme.ts

'use client';

import {
  Dispatch,
  SetStateAction,
  createContext,
  useContext,
  useEffect,
  useMemo,
} from 'react';
import { useLocalStorage } from 'react-use';

export type ColorTheme = 'light' | 'dark' | undefined;
export interface ColorThemeContextType {
  colorTheme: ColorTheme;
  changeColorTheme: Dispatch<SetStateAction<ColorTheme>>;
  removeColorTheme: () => void;
}
const ColorThemeContext = createContext<ColorThemeContextType>({
  colorTheme: undefined,
  changeColorTheme: () => {},
  removeColorTheme: () => {},
});

const STORAGE_KEY = 'color-theme';

function ColorThemeContextProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [colorTheme, changeColorTheme, removeColorTheme] =
    useLocalStorage<ColorTheme>(STORAGE_KEY);

  const value = useMemo(() => {
    return {
      colorTheme,
      changeColorTheme,
      removeColorTheme,
    };
  }, [colorTheme, changeColorTheme, removeColorTheme]);

  useEffect(() => {
    if (isDarkMode(colorTheme)) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [colorTheme]);

  return (
    <ColorThemeContext.Provider value={value}>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            if ((localStorage['${STORAGE_KEY}'] !== undefined && JSON.parse(localStorage['${STORAGE_KEY}']) === 'dark') || (!('${STORAGE_KEY}' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
              document.documentElement.classList.add('dark')
            } else {
              document.documentElement.classList.remove('dark')
            }
          `,
        }}
      />
      {children}
    </ColorThemeContext.Provider>
  );
}

function useColorThemeStore() {
  return useContext(ColorThemeContext);
}

function isDarkMode(colorTheme: ColorTheme) {
  if (colorTheme === 'dark') {
    return true;
  }

  if (
    colorTheme === undefined &&
    window.matchMedia('(prefers-color-scheme: dark)').matches
  ) {
    return true;
  }

  return false;
}

export default ColorThemeContextProvider;
export { useColorThemeStore, isDarkMode };

tailwind.config.js でダークモードの設定をする

Tailwind CSS でダークモードを有効にするためには設定を編集する必要があります。

// tailwind.config.js

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

この設定を行うことで、html タグに dark という class 属性の付け外しを行うだけでダークモードを切り替えることができるようになります。

現在のテーマの状態を管理するための React Context を作成する

ダークモード機能を実装するためには、現在がダークモードなのかライトモードなのかといった状態を管理する必要があります。今回はこれらの状態をカラーテーマという名前で管理します。

React Context を利用します。

app/stores/color-theme.ts

'use client';

import {
  Dispatch,
  SetStateAction,
  createContext,
  useContext,
  useEffect,
  useMemo,
} from 'react';
import { useLocalStorage } from 'react-use';

export type ColorTheme = 'light' | 'dark' | undefined;
export interface ColorThemeContextType {
  colorTheme: ColorTheme;
  changeColorTheme: Dispatch<SetStateAction<ColorTheme>>;
  removeColorTheme: () => void;
}
const ColorThemeContext = createContext<ColorThemeContextType>({
  colorTheme: undefined,
  changeColorTheme: () => {},
  removeColorTheme: () => {},
});

const STORAGE_KEY = 'color-theme';

function ColorThemeContextProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [colorTheme, changeColorTheme, removeColorTheme] =
    useLocalStorage<ColorTheme>(STORAGE_KEY);

  const value = useMemo(() => {
    return {
      colorTheme,
      changeColorTheme,
      removeColorTheme,
    };
  }, [colorTheme, changeColorTheme, removeColorTheme]);

  useEffect(() => {
    if (isDarkMode(colorTheme)) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [colorTheme]);

  return (
    <ColorThemeContext.Provider value={value}>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            if ((localStorage['${STORAGE_KEY}'] !== undefined && JSON.parse(localStorage['${STORAGE_KEY}']) === 'dark') || (!('${STORAGE_KEY}' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
              document.documentElement.classList.add('dark')
            } else {
              document.documentElement.classList.remove('dark')
            }
          `,
        }}
      />
      {children}
    </ColorThemeContext.Provider>
  );
}

function useColorThemeStore() {
  return useContext(ColorThemeContext);
}

function isDarkMode(colorTheme: ColorTheme) {
  if (colorTheme === 'dark') {
    return true;
  }

  if (
    colorTheme === undefined &&
    window.matchMedia('(prefers-color-scheme: dark)').matches
  ) {
    return true;
  }

  return false;
}

export default ColorThemeContextProvider;
export { useColorThemeStore, isDarkMode };

react-use というライブラリから提供されている useLocalStorage という React Hook を利用して状態を localStorage に保存して管理できるようにしています。

現在のカラーテーマをlocalStorageで管理することでブラウザをリロードした場合にも状態を引き継ぐことができます。

また、カラーテーマを colorTheme という変数で管理して、変更があった場合、html タグの dark クラスを操作しています。

ここで作成した ColorThemeContextProvider を layout.tsx で body タグ直下に適用します。

// layout.tsx

import ColorThemeContextProvider from './stores/color-theme';
import './globals.css';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body>
        <ColorThemeContextProvider>
          {children}
        </ColorThemeContextProvider>
      </body>
    </html>
  );
}

注意点として、html タグに suppressHydrationWarning を適用する必要があります。これにより、React が発生させるハイドレーションの不一致警告を抑制することができます。

ロード時のチラツキを防止する

React Context による実装ではブラウザで React のコードが実行され、カラーテーマが適用されるまでタイムラグが発生します。このタイムラグにより、ロード時に意図しないチラツキが発生してしまう場合があります。

これを防ぐための工夫として、SSGやSSRによってサーバーから返却されるHTMLにカラーテーマを適用する処理をscriptタグで埋め込んでいます。

これにより、ロード時にReact のコードが適用されるまでに発生してしまうチラツキを防ぐことができます。

<script
        dangerouslySetInnerHTML={{
          __html: `
            if ((localStorage['${STORAGE_KEY}'] !== undefined && JSON.parse(localStorage['${STORAGE_KEY}']) === 'dark') || (!('${STORAGE_KEY}' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
              document.documentElement.classList.add('dark')
            } else {
              document.documentElement.classList.remove('dark')
            }
          `,
        }}
      />

テーマカラーを切り替える

ダークモードを適用することができるようになったので、次はWebサイトに訪れたユーザーがテーマカラーを切り替えられるようなUIを作成します。

コンポーネントライブラリとして Flowbite を利用しています。

// app/components/ThemeColorSwitcher/ThemeColorSwitcher.tsx

'use client';

import { useBrowser } from '@/app/hooks';
import { isDarkMode, useColorThemeStore } from '@/app/stores/color-theme';
import clsx from 'clsx';
import { initDropdowns } from 'flowbite';
import React, { useEffect, useId, useMemo } from 'react';

const colorThemes = [
  {
    name: 'light',
    icon: (
      <svg
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="24"
        fill="none"
        viewBox="0 0 24 24"
      >
        <path
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth="2"
          d="M12 5V3m0 18v-2M7.05 7.05 5.636 5.636m12.728 12.728L16.95 16.95M5 12H3m18 0h-2M7.05 16.95l-1.414 1.414M18.364 5.636 16.95 7.05M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
        />
      </svg>
    ),
  },
  {
    name: 'dark',
    icon: (
      <svg
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="24"
        fill="none"
        viewBox="0 0 24 24"
      >
        <path
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth="2"
          d="M12 21a9 9 0 0 1-.5-17.986V3c-.354.966-.5 1.911-.5 3a9 9 0 0 0 9 9c.239 0 .254.018.488 0A9.004 9.004 0 0 1 12 21Z"
        />
      </svg>
    ),
  },
  {
    name: 'system',
    icon: (
      <svg
        aria-hidden="true"
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="24"
        fill="none"
        viewBox="0 0 24 24"
      >
        <path
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth="2"
          d="M12 15v5m-3 0h6M4 11h16M5 15h14a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1Z"
        />
      </svg>
    ),
  },
];

function ThemeColorSwitcher() {
  const id = useId();
  const isBrowser = useBrowser();

  useEffect(() => {
    if (isBrowser) {
      initDropdowns();
    }
  }, [isBrowser]);

  const { colorTheme, changeColorTheme, removeColorTheme } =
    useColorThemeStore();

  const colorThemeName = useMemo(() => colorTheme ?? 'system', [colorTheme]);

  function handleClick(colorTheme: 'light' | 'dark' | 'system') {
    if (colorTheme === 'system') {
      removeColorTheme();
    } else {
      changeColorTheme(colorTheme);
    }
    // @ts-ignore
    const dropdown = FlowbiteInstances.getInstance(
      'Dropdown',
      `dropdown-${id}`,
    );
    dropdown.hide();
  }

  function renderIcon(colorTheme?: 'light' | 'dark') {
    if (isBrowser && isDarkMode(colorTheme)) {
      return colorThemes.find((theme) => theme.name === 'dark')?.icon;
    }

    return colorThemes.find((theme) => theme.name === 'light')?.icon;
  }

  if (!isBrowser) {
    return null;
  }

  return (
    <>
      <button
        id={id}
        data-dropdown-toggle={`dropdown-${id}`}
        className={clsx(
          'inline-flex h-10 w-10 items-center justify-center rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:hover:bg-gray-700 dark:focus:ring-gray-700',
          {
            'text-primary': isBrowser && colorTheme !== undefined,
            'text-gray-800 dark:text-white':
              isBrowser && colorTheme === undefined,
          },
        )}
        type="button"
      >
        {renderIcon(colorTheme)}
      </button>
      <div
        id={`dropdown-${id}`}
        className="z-10 hidden w-36 divide-y divide-gray-100 rounded-lg bg-white shadow dark:bg-gray-700"
      >
        <ul
          className="py-2 text-sm text-gray-700 dark:text-gray-200"
          aria-labelledby="themeColorSwitcher"
        >
          {colorThemes.map((theme) => (
            <li
              key={theme.name}
              className={clsx('group', {
                'is-selected': theme.name === colorThemeName,
              })}
            >
              <button
                type="button"
                className="block w-full px-4 py-2 text-gray-800 hover:bg-gray-100 group-[.is-selected]:text-primary dark:text-white dark:hover:bg-gray-600"
                onClick={() =>
                  handleClick(theme.name as 'light' | 'dark' | 'system')
                }
              >
                <span className="flex items-center gap-x-2">
                  <span>{theme.icon}</span>
                  <span className="font-bold capitalize">{theme.name}</span>
                </span>
              </button>
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

export default ThemeColorSwitcher;
app/hooks/useBrowser.tsx

import { useEffect, useState } from 'react';

export default function useBrowser() {
  const [isBrowser, setIsBrowser] = useState(false);
  useEffect(() => {
    setIsBrowser(true);
  }, [setIsBrowser]);

  return isBrowser;
}

現在のテーマを管理するために作成した React Context を、useColorThemeStore フック経由で利用してカラーテーマの切り替えを実装します。

useBrowser というカスタムフックを利用して、ブラウザで実行される場合のみ実行したい処理を記述しています。

現在のカラーテーマによって処理を分岐する必要がありますが、サーバーサイドでは現在のカラーテーマを判定できないためです。

if (!isBrowser) {
  return null;
}

また、上記のコードでサーバーサイドレンダリングではコンポーネントをレンダリングしないようにしています。これにより、React が発生させるハイドレーションの不一致を抑制することができます。

これで一通りの実装は完了です。あとは、作成した ThemeColorSwitcher コンポーネントを Webサイトの好きなところに設置してください。

また、 Tailwind CSS の作法に従い、 dark バリアントを利用してダークモード時のスタイルを定義する必要があります。

今回は Flowbite を利用していたためほとんど新しくスタイルを書くこと無くダークモードを適用することができました。

まとめ

今回は基本的には自前で実装しましたが、next-themes というライブラリを利用することでより簡単に実装できるようです。