概要
React Context でアクティブなテーマ名を管理し、<body> に CSS カスタムプロパティをセットすることでコンポーネントがテーマを自動追従する。コンポーネント側は var(--color-primary) を参照するだけでよく、テーマの追加・変更が Context とトークン定義だけで完結する。
インストール
# 追加インストールは不要
実装
テーマトークン定義
// lib/themes.ts
export type ThemeName = "light" | "dark" | "ocean";
type Tokens = {
"--color-bg": string;
"--color-surface": string;
"--color-primary": string;
"--color-text": string;
"--color-text-muted": string;
};
export const themes: Record<ThemeName, Tokens> = {
light: {
"--color-bg": "#ffffff",
"--color-surface": "#f3f4f6",
"--color-primary": "#2563eb",
"--color-text": "#111827",
"--color-text-muted": "#6b7280",
},
dark: {
"--color-bg": "#0f172a",
"--color-surface": "#1e293b",
"--color-primary": "#60a5fa",
"--color-text": "#f1f5f9",
"--color-text-muted": "#94a3b8",
},
ocean: {
"--color-bg": "#0c4a6e",
"--color-surface": "#075985",
"--color-primary": "#38bdf8",
"--color-text": "#f0f9ff",
"--color-text-muted": "#bae6fd",
},
};
ThemeContext と Provider
// contexts/ThemeContext.tsx
"use client";
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { themes, type ThemeName } from "@/lib/themes";
type ThemeContextValue = {
theme: ThemeName;
setTheme: (name: ThemeName) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeName>("light");
function setTheme(name: ThemeName) {
setThemeState(name);
const tokens = themes[name];
for (const [key, value] of Object.entries(tokens)) {
document.body.style.setProperty(key, value);
}
}
useEffect(() => {
setTheme("light");
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}
レイアウトへの組み込み
// app/layout.tsx
import { ThemeProvider } from "@/contexts/ThemeContext";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body style={{ backgroundColor: "var(--color-bg)", color: "var(--color-text)" }}>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
テーマ切り替え UI
// components/ThemeSwitcher.tsx
"use client";
import { useTheme } from "@/contexts/ThemeContext";
import type { ThemeName } from "@/lib/themes";
const OPTIONS: { value: ThemeName; label: string }[] = [
{ value: "light", label: "ライト" },
{ value: "dark", label: "ダーク" },
{ value: "ocean", label: "オーシャン" },
];
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div style={{ display: "flex", gap: "8px" }}>
{OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={theme === opt.value}
style={{
padding: "6px 14px",
borderRadius: "6px",
fontSize: "0.875rem",
border: "1px solid var(--color-primary)",
background: theme === opt.value ? "var(--color-primary)" : "transparent",
color: theme === opt.value ? "var(--color-bg)" : "var(--color-primary)",
cursor: "pointer",
}}
>
{opt.label}
</button>
))}
</div>
);
}
CSS 変数を使うコンポーネント例
// components/ThemedCard.tsx
export function ThemedCard({ title, body }: { title: string; body: string }) {
return (
<div
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
borderRadius: "8px",
padding: "16px",
}}
>
<h2 style={{ color: "var(--color-primary)", marginBottom: "8px" }}>{title}</h2>
<p style={{ color: "var(--color-text-muted)", fontSize: "0.875rem" }}>{body}</p>
</div>
);
}
ポイント
ThemeProviderが CSS 変数をdocument.body.style.setProperty()でセットするため、コンポーネント側はvar(--color-primary)を参照するだけでテーマを追従できるtailwind-dark-modeとの違い: Tailwind はdark:クラスを HTML に付与する方式。このパターンは CSS 変数をトークンとして持ち、テーマ数や色種が増えても同じ仕組みで対応できる- テーマトークンを
lib/themes.tsに集約することで、新テーマ追加が 1 ファイルの変更で済む useTheme()にnullチェックを入れておくと、Provider 外での誤用をランタイムで検出できる- 永続化が必要な場合は
useEffect内でlocalStorageに書き込み、初回マウント時に読み出してsetTheme()を呼ぶ