Next.js × shadcn/ui で簡単ダークモード実装

## はじめに
Next.js と shadcn/ui を組み合わせて、簡単にダークモードを実装する方法を紹介します。shadcn/ui は、Radix UI をベースにしたコンポーネントライブラリで、柔軟なカスタマイズ性が特徴です。

## 環境構築
### Next.jsプロジェクトの作成
まず、Next.js 公式ドキュメントを参考にプロジェクトを作成します。

https://nextjs-ja-translation-docs.vercel.app/docs/getting-started

:::note warn
shadcn/ui は Tailwind を使用するため、以下は Yes を選択してください。
```
Would you like to use Tailwind CSS? Yes
```
:::

### shadcn/uiの導入
shadcn/ui 公式ドキュメントを参考に shadcn/ui をプロジェクトに追加します。

https://ui.shadcn.com/docs/installation/next

```sh:sh
npx shadcn@latest init
```

### UIコンポーネントの追加
モードの切替に使用する Switch コンポーネントと、見た目の確認用に Button コンポーネントを追加します。

```sh:sh
npx shadcn@latest add switch
npx shadcn@latest add button
```

:::note
shadcn/ui のコンポーネントはデフォルトでは`src/components/ui`に追加されます。
デフォルトの格納場所は、`components.json`で設定を変更できます。
:::

### UIコンポーネントの配置
作成した UI コンポーネントを page.tsx や header.tsx に追加します。
モードの状態がわかりやすいように Switch の左右にアイコンを配置します。
スタイルは適宜調整してください。

```tsx:tsx
//app/page.tsx
import React from 'react';
import { Button } from '@/app/components/ui/button';
import { Switch } from '@/app/components/ui/switch';
import { Moon, Sun } from "lucide-react" //`太陽と月のマーク
export default function Home() {
  return (
    <main className="flex justify-center items-center">
      <div className="flex items-center space-x-8 min-h-screen">
        <h1>My Application</h1>
        <Button>Button</Button> {/*ただのボタン*/}
        <div className="flex items-center space-x-2">
          <Sun className="h-4 w-4" />
          <Switch /> {/*モード切替用のボタン*/}
          <Moon className="h-4 w-4" />
        </div>
      </div>
    </main>
  );
}
```

こんな感じになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2153171/468fef38-e3fc-4974-bafa-3cb654bd7cdc.png)

## ダークモードの設定
### next-themes のインストール
shadcn/ui は next-themes を使用してテーマを管理できます。
公式ドキュメントの手順通りに next-themes をインストールします。

https://ui.shadcn.com/docs/dark-mode/next

```sh:sh
npm install next-themes
```

### ThemeProvider の作成
```tsx:tsx
// app/theme-provider.tsx
'use client';
import { ThemeProvider as NextThemeProvider } from 'next-themes';
import { useEffect, useState } from 'react';
export const ThemeProvider = ({ children, ...props }: React.ComponentProps<typeof NextThemeProvider>) => {
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  if (!mounted) return null; // クライアント側でのみレンダリング
  return <NextThemeProvider {...props}>{children}</NextThemeProvider>;
};
```

:::note warn
**ハイドレーションエラーの回避**
next-themes を使ってダークモードを実装すると、Hydration error（サーバー側とクライアント側のデータの不一致）が発生することがあります。
そのため mounted 状態を管理し、クライアントサイドでのみレンダリングを行うことで、ハイドレーションエラーを回避します。
参考：[React公式ドキュメント](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content)
:::
### ThemeProviderをルートレイアウトに適用
```tsx:tsx
// app/layout.tsx
import React from "react"
import "@/app/styles/globals.css";
import { ThemeProvider } from "@/app/theme-provider"
interface RootLayoutProps {
  children: React.ReactNode
}
export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <>
      <html lang="ja">
        <body className="bg-background text-foreground">
          <ThemeProvider
            attribute="class"
            defaultTheme="light"
            enableSystem
            disableTransitionOnChange
          >
            {children}
          </ThemeProvider>
        </body>
      </html>
    </>
  )
}
```

### Themeの切替の処理を追加
```tsx:tsx
//app/page.tsx
'use client' // 追加
import React from 'react';// 追加
import { Button } from '@/app/components/ui/button';
import { Switch } from '@/app/components/ui/switch';
import { Moon, Sun } from "lucide-react"
import { useTheme } from 'next-themes';
export default function Home() {
  const { theme, setTheme } = useTheme(); // 追加
  return (
    <main className="flex justify-center items-center">
      <div className="flex items-center space-x-8 min-h-screen">
        <h1>My Application</h1>
        <Button>Button</Button>
        <div className="flex items-center space-x-2">
          <Sun className="h-4 w-4" />
          <Switch checked={theme === "dark"} onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")} /> {/* 追加 */}
          <Moon className="h-4 w-4" />
        </div>
      </div>
    </main>
  );
}
```

これで実装は完了です。モードによって、以下のように背景色やテキストの色が変わります。
▼ライトモード
![画像2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2153171/ae3b4362-2ce7-4f6c-bab2-747461d81009.png)
▼ダークモード
![画像1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2153171/a927d32a-84c8-4e2c-a696-d5ac2918f4a6.png)

## 色のカスタマイズ
ライトモード、ダークモードの色は`globals.css`でデフォルトで設定されているので、お好みに応じて変更してください。

```css:css
/*app/styles/globals.css*/
@layer base {
  :root {
    --background: oklch(1 0 0);
    --foreground: oklch(0.129 0.042 264.695);
    /* 省略 */
  }
  .dark {
    --background: oklch(0.129 0.042 264.695);
    --foreground: oklch(0.984 0.003 247.858);
    /* 省略 */
  }
}
```

## まとめ
以上で、Next.js と shadcn/ui を使ったダークモードの実装が完了しました。
next-themes を利用することで簡単にテーマを切り替えられます。
また、shadcn/ui のコンポーネントはカスタマイズしやすいので、好みに合わせてデザインを調整してみてください！

***
オブジェクティブグループではXの投稿も平日毎日行っています！
IT関連の小ネタや便利技から、日常のアニメ・ゲーム布教なども幅広く投稿してるので、
ご興味のある方は是非フォロー・いいねをお願いします。

https://x.com/obg_ocr