レビュー待ち·難易度: 中級·更新: 2026-04-18

Context と CSS カスタムプロパティでテーマシステムを構築する

React Context で現在のテーマを管理し、CSS カスタムプロパティ(CSS 変数)でコンポーネントがテーマを自動追従するシステムを実装する例。

nextjsstylingstate-management

対応バージョン

nextjs 15react 19

前提環境

React の useContext と CSS カスタムプロパティの基本を理解していること

概要

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() を呼ぶ

注意点

tailwind-dark-mode はクラス切り替え + localStorage。css-modules-component はスコープ CSS。これは CSS 変数(--color-primary 等)を Context 経由でテーマ別に切り替え、コンポーネントが CSS 変数を参照するだけでテーマを自動追従するパターン。

関連サンプル