はじめに
ダークモードは現代のWebアプリケーションに欠かせない機能です。この記事では、Next.js App Routerでダークモードを実装する複数の方法を解説します。
next-themesを使った実装(推奨)
セットアップ
npm install next-themes
プロバイダーの設定
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
テーマ切り替えコンポーネント
// components/ThemeToggle.tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { SunIcon, MoonIcon, ComputerDesktopIcon } from '@heroicons/react/24/outline';
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme, resolvedTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className="w-9 h-9 rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse" />
);
}
return (
<div className="flex items-center gap-2">
<button
onClick={() => setTheme('light')}
className={`p-2 rounded-lg transition-colors ${
theme === 'light'
? 'bg-yellow-100 text-yellow-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
aria-label="ライトモード"
>
<SunIcon className="w-5 h-5" />
</button>
<button
onClick={() => setTheme('dark')}
className={`p-2 rounded-lg transition-colors ${
theme === 'dark'
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
aria-label="ダークモード"
>
<MoonIcon className="w-5 h-5" />
</button>
<button
onClick={() => setTheme('system')}
className={`p-2 rounded-lg transition-colors ${
theme === 'system'
? 'bg-gray-200 dark:bg-gray-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
aria-label="システム設定に従う"
>
<ComputerDesktopIcon className="w-5 h-5" />
</button>
</div>
);
}
ドロップダウン型切り替え
// components/ThemeDropdown.tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState, useRef } from 'react';
const themes = [
{ value: 'light', label: 'ライト', icon: '☀️' },
{ value: 'dark', label: 'ダーク', icon: '🌙' },
{ value: 'system', label: 'システム', icon: '💻' },
];
export function ThemeDropdown() {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme();
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
if (!mounted) {
return <div className="w-32 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" />;
}
const currentTheme = themes.find((t) => t.value === theme);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<span>{currentTheme?.icon}</span>
<span>{currentTheme?.label}</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute top-full mt-2 w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
{themes.map((t) => (
<button
key={t.value}
onClick={() => {
setTheme(t.value);
setIsOpen(false);
}}
className={`w-full flex items-center gap-2 px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
theme === t.value ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
>
<span>{t.icon}</span>
<span>{t.label}</span>
</button>
))}
</div>
)}
</div>
);
}
Tailwind CSSの設定
darkModeの設定
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// カスタムカラーの定義
background: {
DEFAULT: '#ffffff',
dark: '#0a0a0a',
},
foreground: {
DEFAULT: '#171717',
dark: '#ededed',
},
},
},
},
plugins: [],
};
CSS変数を使ったテーマ
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
// tailwind.config.js(CSS変数版)
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ['./app/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};
フラッシュ防止
インラインスクリプト
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
function getTheme() {
const stored = localStorage.getItem('theme');
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
const theme = getTheme();
document.documentElement.classList.toggle('dark', theme === 'dark');
})();
`,
}}
/>
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
カスタム実装(next-themesなし)
Context API版
// contexts/ThemeContext.tsx
'use client';
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
} from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const applyTheme = useCallback((newTheme: Theme) => {
let resolved: 'light' | 'dark';
if (newTheme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
} else {
resolved = newTheme;
}
setResolvedTheme(resolved);
document.documentElement.classList.toggle('dark', resolved === 'dark');
}, []);
const setTheme = useCallback(
(newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
},
[applyTheme]
);
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null;
const initialTheme = stored || 'system';
setThemeState(initialTheme);
applyTheme(initialTheme);
}, [applyTheme]);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
applyTheme('system');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, applyTheme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
ダークモード対応のコンポーネント例
カードコンポーネント
// components/Card.tsx
interface CardProps {
title: string;
description: string;
children?: React.ReactNode;
}
export function Card({ title, description, children }: CardProps) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm transition-colors">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
<p className="mt-2 text-gray-600 dark:text-gray-400">{description}</p>
{children && <div className="mt-4">{children}</div>}
</div>
);
}
ボタンコンポーネント
// components/Button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline';
children: React.ReactNode;
}
export function Button({
variant = 'primary',
children,
className = '',
...props
}: ButtonProps) {
const baseStyles =
'px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
const variants = {
primary:
'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600',
secondary:
'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-100',
outline:
'border border-gray-300 hover:bg-gray-100 text-gray-700 focus:ring-gray-500 dark:border-gray-600 dark:hover:bg-gray-800 dark:text-gray-300',
};
return (
<button className={`${baseStyles} ${variants[variant]} ${className}`} {...props}>
{children}
</button>
);
}
まとめ
ダークモード実装のポイントをまとめます。
| 方法 | 推奨度 | 特徴 |
|---|---|---|
| next-themes | ★★★ | 最も簡単、フラッシュ防止済み |
| CSS変数 | ★★☆ | 柔軟なカスタマイズ |
| カスタム実装 | ★☆☆ | 完全な制御が必要な場合 |