チュートリアル

Reactにおける動的カラーテーミング:CSSカスタムプロパティを超えて

4分で読める

ほとんどのテーミングチュートリアルは暗いモード切り替えで終わります。<html>にクラスを切り替え、一握りのCSSカスタムプロパティをフリップして完了です。それは一般的な場合をカバーしますが、より興味深い問題を見逃しています。ユーザーが独自のブランドカラーを選択できるとしたらどうでしょうか。SaaS製品が複数のクライアントに奉仕する場合、それぞれが独自のアイデンティティを持つ場合はどうでしょうか。テーマがランタイム時に単一の入力HEX値から調和の取れた完全なパレットを生成する必要がある場合はどうでしょうか。

それは動的カラーテーミングが始まるところです。そしてCSSカスタムプロパティ単独では十分ではありません。ランタイムカラーアルゴリズム、ナビゲーション後に継続するReactの状態、永続化戦略、そしてスライダーが動くたびにコンポーネントツリー全体を再レンダリングしないアーキテクチャが必要です。

このガイドはこれらの各レイヤーを詳しくカバーしています。


ReactテーマのCSSカスタムプロパティアプローチ

ベースライン:セマンティックトークンアーキテクチャ

すべての動的テーマシステムは同じ基盤から始まります。CSSカスタムプロパティは、色の値ではなく意味的な役割で名付けられます。--blue-500と名付けてはいけません。--brand-primaryと名付けてください。値は変わる可能性があります。役割は変わりません。

/* globals.css */
:root {
  /* セマンティックトークン — これはコンポーネントが使用するもの */
  --bg-base: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-sunken: #F1F5F9;

  --text-primary: #1E293B;
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;

  --brand-primary: #2563EB;
  --brand-hover: #1D4ED8;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;

  --status-error: #DC2626;
  --status-success: #16A34A;
  --status-warning: #D97706;
}

コンポーネントはトークンを参照し、決して生の色ではありません。

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

.btn-primary {
  background: var(--brand-primary);
  color: var(--on-brand);
}

.btn-primary:hover {
  background: var(--brand-hover);
}

JavaScriptで--brand-primaryを更新すると、それを参照するすべてのコンポーネントが即座に更新されます。Reactの再レンダリングなしに、prop drilling無しに、コンテキストサブスクリプション無しに。CSSがカスケードを処理します。

JavaScriptからトークンを書き込む

DOMカスタムプロパティを設定するAPIは簡潔です。

document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');

それらをを読み戻すと:

const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand-primary')
  .trim();

これは機械的な基礎です。難しい部分は、単一のブランドHEXから完全なトークンセットを生成することです。


ランタイムテーマ生成とカラーアルゴリズム

マニュアルトークンセットの問題

ユーザーが任意の色を選択できる場合(#FF5733#0D9488のような異常なものも含めて)、ダークバリアント、ホバー状態、またはブランドテキストカラーをハードコードすることはできません。入力から計算する必要があります。

完全なトークンセットを生成する

ブランドHEXを取得してテーマが必要とするすべてのトークンを生成する完全な関数は次のとおりです。

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // WCAG相対輝度に基づいてブランドテキストが黒か白かを判定
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // OKLCHで軽いティントから暗いシェードまでスケールを生成
  // 知覚的に均一なステップ用
  const scale = chroma.scale([
    chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
    brandHex,
    chroma(brandHex).darken(2.5).hex(),
  ]).mode('oklch').colors(11);

  return {
    '--brand-primary': brandHex,
    '--brand-hover': chroma(brandHex).darken(0.5).hex(),
    '--brand-active': chroma(brandHex).darken(1).hex(),
    '--brand-subtle': scale[1],   // 非常に明るいティント
    '--brand-muted': scale[2],    // 明るいティント
    '--on-brand': onBrand,

    // スケールプリミティブ
    '--brand-50': scale[0],
    '--brand-100': scale[1],
    '--brand-200': scale[2],
    '--brand-300': scale[3],
    '--brand-400': scale[4],
    '--brand-500': scale[5],
    '--brand-600': scale[6],
    '--brand-700': scale[7],
    '--brand-800': scale[8],
    '--brand-900': scale[9],
    '--brand-950': scale[10],
  };
}

function applyTheme(brandHex) {
  const tokens = generateThemeTokens(brandHex);
  const root = document.documentElement;
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
}

アクセス可能なカラーペア

任意のユーザー入力からテーマを生成するときは、アクセシビリティを想定することはできません。ユーザーが選択した色は、白に対して不十分なコントラストを生じさせる可能性があります。確認と調整が必要です。

function ensureContrast(foreground, background, minRatio = 4.5) {
  let color = chroma(foreground);
  let ratio = chroma.contrast(color.hex(), background);

  // コントラストが不十分な場合、比率が通過するまで前景を暗くする
  let iterations = 0;
  while (ratio < minRatio && iterations < 20) {
    color = color.darken(0.2);
    ratio = chroma.contrast(color.hex(), background);
    iterations++;
  }

  return color.hex();
}

// ブランドカラーのテキストラベル用(白い背景上)
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // 黄色はAAを渡すために調整

この状態を白または他の固定サーフェスに表示されるテキスト要素を生成するときに適用します。

補色およびアクセントカラー

パレットジェネレーターは、単一のベースカラーがどのように完全な調和ファミリーを生成するかを示します。色相回転を使用してコード内でそのロジックを複製できます。

function generateComplementaryAccent(brandHex) {
  const hsl = chroma(brandHex).hsl();
  // バランスを維持するために色相を180°回転し、明るさをシフト
  const complementHue = (hsl[0] + 180) % 360;
  return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}

function generateAnalogousAccent(brandHex, degrees = 30) {
  const hsl = chroma(brandHex).hsl();
  const analogousHue = (hsl[0] + degrees) % 360;
  return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}

テーマ状態管理のためのReactコンテキスト

ThemeContextアーキテクチャ

テーマコンテキストは、ユーザーインタラクション(カラーピッキング、ダークモード切り替え)とDOM レベルトークンアプリケーション間のブリッジとして機能します。薄く保つべきです。状態を保存し、DOMに適用し、セッターを公開します。カラー計算はコンテキスト外で発生します。

// contexts/ThemeContext.tsx
import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
  type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';

interface ThemeState {
  brandHex: string;
  mode: 'light' | 'dark' | 'system';
}

interface ThemeContextValue extends ThemeState {
  setBrand: (hex: string) => void;
  setMode: (mode: 'light' | 'dark' | 'system') => void;
  resolvedMode: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

const STORAGE_KEY = 'app-theme';

function getStoredTheme(): ThemeState {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) return JSON.parse(stored) as ThemeState;
  } catch {}
  return { brandHex: '#2563EB', mode: 'system' };
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>(getStoredTheme);

  const systemDark =
    typeof window !== 'undefined'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
      : false;

  const resolvedMode: 'light' | 'dark' =
    state.mode === 'system'
      ? systemDark ? 'dark' : 'light'
      : state.mode;

  // 状態が変わるたびにCSSトークンを適用
  useEffect(() => {
    const tokens = generateThemeTokens(state.brandHex);
    const root = document.documentElement;
    Object.entries(tokens).forEach(([prop, value]) => {
      root.style.setProperty(prop, value as string);
    });
    root.setAttribute('data-theme', resolvedMode);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state, resolvedMode]);

  // システム環境設定の変更に対応
  useEffect(() => {
    if (state.mode !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light',
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [state.mode]);

  const setBrand = useCallback((hex: string) => {
    setState(prev => ({ ...prev, brandHex: hex }));
  }, []);

  const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
    setState(prev => ({ ...prev, mode }));
  }, []);

  return (
    <ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

カラーピッカーコンポーネント

コンテキストを配置すると、カラーピッカーは薄いUIレイヤーになります。

// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';

export function BrandColorPicker() {
  const { brandHex, setBrand } = useTheme();
  const [localHex, setLocalHex] = useState(brandHex);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalHex(value);

    // 高コストなトークン生成をデバウンス
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
        setBrand(value);
      }
    }, 150);
  };

  return (
    <div className="flex items-center gap-3">
      <label htmlFor="brand-picker" className="text-sm font-medium">
        ブランドカラー
      </label>
      <div className="relative">
        <input
          id="brand-picker"
          type="color"
          value={localHex}
          onChange={handleChange}
          className="sr-only"
        />
        <label
          htmlFor="brand-picker"
          className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
          style={{
            backgroundColor: localHex,
            // リングカラーはブランドトークンを使用
            '--tw-ring-color': 'var(--brand-primary)',
          } as React.CSSProperties}
          aria-label={`現在のブランドカラー:${localHex}`}
        />
      </div>
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
        aria-label="ブランドカラーHEX値"
      />
    </div>
  );
}

150msのデバウンスは重要です。なしでは、HEX入力で入力するたびにgenerateThemeTokensが実行され、不必要に高コストです。


Tailwind CSS動的カラークラス

Tailwind とランタイムカラーの課題

Tailwindはビルド時にユーティリティクラスを生成します。bg-brand-500のようなクラスは、Tailwindのスキャナーがソースファイル内で見つけた場合にのみ、スタイルシートに存在します。ランタイム決定カラー(ユーザーが選んだばかり)はスタイルシートに含まれません。

2つのソリューションがあり、それぞれ異なるニーズを提供します。

ソリューション1:CSSカスタムプロパティブリッジ

最もクリーンなアプローチは、CSSカスタムプロパティを指す小さなTailwindテーマトークンセットを定義し、ランタイム時にそれらのプロパティの値を制御することです。

/* styles.css (Tailwind v4) */
@import "tailwindcss";

@theme {
  --color-brand: var(--brand-primary);
  --color-brand-hover: var(--brand-hover);
  --color-brand-subtle: var(--brand-subtle);
  --color-on-brand: var(--on-brand);
}

現在、bg-brandtext-brandhover:bg-brand-hoverは、その時点で--brand-primaryが保持する値を反映する有効なTailwindクラスです。

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  プライマリーアクション
</button>

JavaScriptを経由して--brand-primaryを変更すると、bg-brandを持つすべての要素が即座に更新されます。再レンダリング不要です。

ソリューション2:ワンオフ動的カラー用のインラインスタイル

コンポーネントが本当にユニークなランタイムカラーを必要とする場合(カラーパレットビューアーのカラースウォッチなど)、インラインスタイルを使用します。Tailwindのbg-[#FF5733]任意値の構文はビルド時に既知の静的値に対して機能しますが、JSXのテンプレート文字列内のbg-[${dynamicHex}]には機能しません。クラスがスタイルシートに存在しません。

// 本当に動的なカラーに対して正しい:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
  aria-label={swatch.name}
/>

// これはランタイムでは機能しません(クラスがスタイルシートにない):
<div className={`bg-[${swatch.hex}]`} /> // 間違い

ユーザーカラー環境設定の永続化

セッション全体でのlocalStorageの永続化

上記のThemeContextは既にuseEffectlocalStorageに書き込みます。SSR互換性のキーパターンは、状態を遅延初期化することです。localStorageを読み取るのみクライアント上で発生し、サーバーレンダリング中には決して発生しない関数をuseStateに渡します。

const [state, setState] = useState<ThemeState>(() => {
  // この関数はクライアント上でのみ実行され、ハイドレーション後
  if (typeof window === 'undefined') {
    return { brandHex: '#2563EB', mode: 'system' };
  }
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
  } catch {
    return { brandHex: '#2563EB', mode: 'system' };
  }
});

ロード時のデフォルトテーマのフラッシュを防止

サーバーレンダリングされたアプリ(Next.js App Router)では、ブラウザが達する前にコンポーネントツリーがサーバー上でレンダリングされます。デフォルトテーマで初期化するとき、保存されたカスタムテーマを持つユーザーはフラッシュを見ます。デフォルトの青は一瞬表示され、その後保存された紫がロードされます。

ソリューションは<head>にブロッキングインラインスクリプトを使用し、ブラウザがピクセルをレンダリングする前に保存されたテーマを適用することです。

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var stored = JSON.parse(localStorage.getItem('app-theme') || '{}');
                  var brand = stored.brandHex || '#2563EB';
                  var mode = stored.mode || 'system';
                  var resolvedMode = mode === 'system'
                    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                    : mode;
                  document.documentElement.setAttribute('data-theme', resolvedMode);
                  // フラッシュを防ぐため、最小限のインライントークン
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

インラインスクリプトはのみ最も重要なトークン(ブランドカラー、テーマモード属性)を同期で適用します。完全なトークンセットはハイドレーション後にThemeProviderによって適用されます。ただし、支配的な視覚的トークンが既に正しいため、ユーザーはフラッシュを見ません。

クッキーを経由したサーバー側永続化

認証されたアプリの場合、クッキー内でテーマを保存(サーバーで読み取り可能)すると、最初のバイトから正しいテーマとのSSRが可能になります。

// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
  const mode = request.cookies.get('theme-mode')?.value ?? 'system';

  // レスポンスヘッダーまたは検索パラメーターを使用してテーマをページに渡す
  const response = NextResponse.next();
  response.headers.set('x-brand-hex', brandHex);
  response.headers.set('x-theme-mode', mode);
  return response;
}

ルートlayout.tsxサーバーコンポーネント内でヘッダーを読み取り、初期テーマをThemeProviderへのpropとして渡します。プロバイダーはサーバー既知テーマで初期化され、フラッシュ全体を排除します。


アクセス可能なダークモードバリアントの生成

ブランドカラーからライトテーマを生成するときは、ダークモードバージョンも生成する必要があります。チャレンジ:ライト背景では上手く機能する同じブランドHEX(例えば#2563EB)は、ダークサーフェスに対してWCAGコントラスト要件に失敗することがよくあります。

堅牢なアプローチでは、同じブランド入力から2つのトークンセットを生成します(1つはライト、1つはダーク)。

function generateDarkTokens(brandHex) {
  const brand = chroma(brandHex);
  const hsl = brand.hsl();

  // ダークモード用に、ダークサーフェスに対するコントラストを確保するためにブランドを明るくする
  const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));

  return {
    '[data-theme="dark"]': {
      '--brand-primary': darkBrand.hex(),
      '--brand-hover': darkBrand.lighten(0.3).hex(),
      '--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
      '--on-brand': '#000000', // ダークモードの軽くなったブランドの上に黒
      '--bg-base': '#0F172A',
      '--bg-surface': '#1E293B',
      '--text-primary': '#F1F5F9',
      '--text-secondary': '#94A3B8',
    },
  };
}

ThemeProviderdata-theme="dark"属性を設定するとき、CSSカスケードはダークモードトークン値を適用します。

生成されたカラーペアがWCAGを通過することを確認するために、パレットジェネレーターを使用して完全なスケールを視覚化し、ステップ間のコントラスト比を確認します。


重要なポイント

  • CSSカスタムプロパティが基礎です。セマンティックトークンは役割で名付けられ、色の値ではなく、コンポーネントを変更することなく完全なテーマ変更を可能にします。
  • 単一のブランドHEXからのランタイムトークン生成には以下が必要です。明るさ/暗さスケール、ブランドテキストのWCAGコントラスト確認、ダークモードバリアントの個別計算。
  • Reactコンテキストはテーマの状態(アクティブなブランドHEX、ライト/ダークモード)を管理し、副作用としてトークンをDOMに適用します。コンポーネントはテーマコンテキストをサブスクライブしてカラーを適用する必要がありません。
  • 継続的な入力(カラーピッカーまたはスライダー)に接続されているときは、高コストなカラー計算をデバウンス(150ms)します。
  • Tailwind CSS用:@themeでCSSカスタムプロパティブリッジを定義し、bg-brandユーティリティはランタイムトークン値を反映します。本当にユニークな要素ごとの動的カラーのみインラインスタイルを使用します。
  • 同期ブロッキングスクリプトで<head>にデフォルトテーマのフラッシュを防止し、最も重要なトークンがレンダリング前に適用されます。
  • SSRアプリでユーザーごとのテーマ用に、クッキーにテーマを保存し、サーバーが正しい初期HTMLをレンダリングできるようにします。
  • シェードジェネレーターを使用してセットの最初のカラーに対して完全な50~950範囲を見直し、妥当性を検証し、パレットジェネレーターを使用してテーマパレット全体で調和とコントラスト比を確認します。

関連カラー

関連ブランド

関連ツール