T-CREATOR

Tailwind CSS マルチブランド設計:CSS 変数と data-theme で横断対応

Tailwind CSS マルチブランド設計:CSS 変数と data-theme で横断対応

複数のブランドやテーマを持つ Web サービスを開発していると、ブランドごとに異なる色やデザインを適用したいケースに直面します。 Tailwind CSS と CSS 変数、そして data-theme 属性を組み合わせることで、保守性の高いマルチブランド設計が実現できるのです。 この記事では、実践的なマルチブランド設計の方法を段階的に解説していきますね。

背景

マルチブランド対応の必要性

現代の Web サービスでは、1 つのコードベースで複数のブランドやテーマを切り替えるニーズが増えています。 例えば、ホワイトレーベルサービスや、企業ごとにカスタマイズされた UI を提供する SaaS などが該当します。

従来のアプローチでは、ブランドごとに CSS ファイルを分けたり、条件分岐でクラス名を切り替えたりしていましたが、これらの方法では保守コストが高くなってしまいます。

以下の図は、マルチブランド対応が必要となる典型的なシステム構成を示しています。

mermaidflowchart TB
    user1["ブランド A のユーザー"]
    user2["ブランド B のユーザー"]
    user3["ブランド C のユーザー"]

    app["共通アプリケーション<br/>コードベース"]

    theme1["テーマ A<br/>カラー/デザイン"]
    theme2["テーマ B<br/>カラー/デザイン"]
    theme3["テーマ C<br/>カラー/デザイン"]

    user1 -->|アクセス| app
    user2 -->|アクセス| app
    user3 -->|アクセス| app

    app -->|適用| theme1
    app -->|適用| theme2
    app -->|適用| theme3

この図から分かるように、1 つのコードベースで複数のテーマを管理し、ユーザーごとに適切なブランドを表示する仕組みが必要になります。

Tailwind CSS の制約

Tailwind CSS は Utility-First なアプローチで開発効率を高めてくれますが、デフォルトの設定ではブランドごとの色変更に対応しにくいという課題があります。 設定ファイルで定義した色は静的なため、実行時に動的に変更することが難しいのです。

課題

従来のアプローチの問題点

マルチブランド対応における従来のアプローチには、以下のような問題点がありました。

1. CSS ファイルの分割管理

ブランドごとに別々の CSS ファイルを作成する方法では、重複コードが増え、保守性が低下します。 新しいコンポーネントを追加する際には、すべてのブランド用 CSS を更新する必要があり、更新漏れのリスクも高まります。

2. 条件分岐による クラス名の切り替え

JavaScript でブランドを判定し、クラス名を動的に切り替える方法も一般的ですが、コンポーネント内に条件分岐が散在してしまいます。 これにより、コードの可読性が下がり、バグの温床になりやすくなるのです。

3. CSS-in-JS の複雑化

CSS-in-JS ライブラリを使用してブランドごとのスタイルを管理する方法もありますが、Tailwind CSS との併用では設計が複雑になります。 また、ビルドサイズの増加やパフォーマンスへの影響も懸念されます。

以下の図は、従来のアプローチにおける課題を整理したものです。

mermaidflowchart LR
    approach1["CSS ファイル分割"]
    approach2["条件分岐<br/>クラス切り替え"]
    approach3["CSS-in-JS"]

    problem1["重複コード増加<br/>保守性低下"]
    problem2["可読性低下<br/>バグリスク"]
    problem3["複雑な設計<br/>パフォーマンス懸念"]

    approach1 -->|課題| problem1
    approach2 -->|課題| problem2
    approach3 -->|課題| problem3

これらの問題を解決するために、CSS 変数と data-theme 属性を活用したアプローチが有効になります。

理想的な設計要件

マルチブランド設計において、以下の要件を満たすことが理想的です。

  1. 単一のコードベース - コンポーネントコードはブランドに依存しない
  2. 動的な切り替え - 実行時にブランドを変更できる
  3. 型安全性 - TypeScript による型チェックが可能
  4. 保守性 - ブランド追加時の変更箇所が最小限
  5. パフォーマンス - ビルドサイズとランタイムパフォーマンスへの影響が少ない

解決策

CSS 変数と data-theme の組み合わせ

CSS 変数(カスタムプロパティ)と HTML の data-* 属性を組み合わせることで、上記の課題を解決できます。 この方法では、ブランドごとの色定義を CSS 変数として管理し、data-theme 属性の値に応じて変数の値を切り替えるのです。

以下の図は、CSS 変数と data-theme を使った設計の全体像を示しています。

mermaidflowchart TB
    html["HTML ルート要素<br/>data-theme='brand-a'"]

    cssVars["CSS 変数定義<br/>--primary: #xxx<br/>--secondary: #yyy"]

    tailwind["Tailwind 設定<br/>colors: { primary: 'var(--primary)' }"]

    component["コンポーネント<br/>className='bg-primary'"]

    render["レンダリング結果<br/>背景色: #xxx"]

    html -->|適用| cssVars
    cssVars -->|参照| tailwind
    tailwind -->|利用| component
    component -->|出力| render

この仕組みにより、コンポーネントコードを変更することなく、data-theme 属性を変更するだけでブランドの切り替えが可能になります。

実装の全体像

実装は以下のステップで進めていきます。

  1. グローバル CSS で CSS 変数を定義
  2. Tailwind 設定ファイルで CSS 変数を参照
  3. React コンポーネントで data-theme を設定
  4. TypeScript で型定義を追加

それぞれのステップを詳しく見ていきましょう。

具体例

ステップ 1: CSS 変数の定義

まず、グローバル CSS ファイルでブランドごとの CSS 変数を定義します。 このファイルでは、ルート要素に対してデフォルトの色を設定し、data-theme 属性の値に応じて変数を上書きします。

以下は、CSS 変数の基本的な定義例です。

css/* globals.css */

/* デフォルトテーマ(ブランド A)の色定義 */
:root {
  --color-primary: 59 130 246; /* blue-500 */
  --color-secondary: 139 92 246; /* violet-500 */
  --color-accent: 236 72 153; /* pink-500 */
  --color-success: 34 197 94; /* green-500 */
  --color-warning: 251 146 60; /* orange-400 */
  --color-danger: 239 68 68; /* red-500 */
  --color-background: 255 255 255; /* white */
  --color-surface: 249 250 251; /* gray-50 */
  --color-text-primary: 17 24 39; /* gray-900 */
  --color-text-secondary: 107 114 128; /* gray-500 */
}

ここで重要なのは、色の値を RGB の数値で記述している点です。 これにより、Tailwind CSS の不透明度ユーティリティ(例: bg-primary​/​50)が正しく動作するようになります。

次に、ブランド B 用のテーマを定義します。

css/* ブランド B のテーマ定義 */
[data-theme='brand-b'] {
  --color-primary: 16 185 129; /* emerald-500 */
  --color-secondary: 14 165 233; /* sky-500 */
  --color-accent: 245 158 11; /* amber-500 */
  --color-success: 132 204 22; /* lime-500 */
  --color-warning: 251 191 36; /* amber-400 */
  --color-danger: 220 38 38; /* red-600 */
  --color-background: 255 255 255;
  --color-surface: 240 253 244; /* green-50 */
  --color-text-primary: 6 78 59; /* emerald-900 */
  --color-text-secondary: 75 85 99; /* gray-600 */
}

続いて、ブランド C のテーマも定義します。

css/* ブランド C のテーマ定義 */
[data-theme='brand-c'] {
  --color-primary: 168 85 247; /* purple-500 */
  --color-secondary: 244 114 182; /* pink-400 */
  --color-accent: 251 146 60; /* orange-400 */
  --color-success: 52 211 153; /* emerald-400 */
  --color-warning: 234 179 8; /* yellow-500 */
  --color-danger: 248 113 113; /* red-400 */
  --color-background: 17 24 39; /* gray-900 */
  --color-surface: 31 41 55; /* gray-800 */
  --color-text-primary: 243 244 246; /* gray-100 */
  --color-text-secondary: 156 163 175; /* gray-400 */
}

ブランド C はダークテーマの例として、背景色とテキスト色を反転させています。 このように、CSS 変数を使うことで、ライトテーマとダークテーマの両方を柔軟に定義できるのです。

ステップ 2: Tailwind 設定の更新

次に、Tailwind CSS の設定ファイルで、先ほど定義した CSS 変数を参照するように設定します。 この設定により、Tailwind のユーティリティクラスが CSS 変数を使用するようになります。

以下は、Tailwind 設定ファイルの例です。

typescript// tailwind.config.ts

import type { Config } from 'tailwindcss';

const config: Config = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      colors: {
        // CSS 変数を参照する色定義
        primary:
          'rgb(var(--color-primary) / <alpha-value>)',
        secondary:
          'rgb(var(--color-secondary) / <alpha-value>)',
        accent: 'rgb(var(--color-accent) / <alpha-value>)',
        success:
          'rgb(var(--color-success) / <alpha-value>)',
        warning:
          'rgb(var(--color-warning) / <alpha-value>)',
        danger: 'rgb(var(--color-danger) / <alpha-value>)',
      },
    },
  },
  plugins: [],
};

export default config;

<alpha-value> プレースホルダーを使用することで、不透明度の調整が可能になります。 例えば、bg-primary​/​50 というクラスを使うと、プライマリカラーの 50% 不透明度が適用されるのです。

背景色やテキスト色についても、同様に CSS 変数を参照するよう設定します。

typescript// tailwind.config.ts の続き

theme: {
  extend: {
    colors: {
      // 前述の色定義に追加
      background: 'rgb(var(--color-background) / <alpha-value>)',
      surface: 'rgb(var(--color-surface) / <alpha-value>)',
    },
    textColor: {
      primary: 'rgb(var(--color-text-primary) / <alpha-value>)',
      secondary: 'rgb(var(--color-text-secondary) / <alpha-value>)',
    },
  },
}

これにより、bg-backgroundbg-surfacetext-primarytext-secondary といったクラスが利用可能になります。

ステップ 3: テーマ切り替えの実装

React コンポーネントで data-theme 属性を動的に設定する仕組みを実装します。 Context API を使用して、アプリケーション全体でテーマを管理できるようにしましょう。

まず、テーマの型定義を作成します。

typescript// types/theme.ts

// 利用可能なテーマの型定義
export type Theme = 'brand-a' | 'brand-b' | 'brand-c';

// テーマコンテキストの型定義
export interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
}

型定義により、存在しないテーマ名を指定した際にコンパイルエラーで検出できるようになります。

次に、テーマコンテキストを作成します。

typescript// contexts/ThemeContext.tsx

'use client';

import {
  createContext,
  useContext,
  useState,
  useEffect,
  ReactNode,
} from 'react';
import type {
  Theme,
  ThemeContextType,
} from '@/types/theme';

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

interface ThemeProviderProps {
  children: ReactNode;
  defaultTheme?: Theme;
}

export function ThemeProvider({
  children,
  defaultTheme = 'brand-a',
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>(defaultTheme);

  // テーマ変更時に HTML 要素に data-theme 属性を設定
  useEffect(() => {
    document.documentElement.setAttribute(
      'data-theme',
      theme
    );
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

useEffect フックを使用して、テーマが変更されるたびに HTML 要素の data-theme 属性を更新します。 これにより、CSS 変数が自動的に切り替わり、画面全体のデザインが変更されるのです。

カスタムフックを作成して、テーマコンテキストへのアクセスを簡単にします。

typescript// contexts/ThemeContext.tsx の続き

export function useTheme() {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error(
      'useTheme must be used within a ThemeProvider'
    );
  }

  return context;
}

このカスタムフックを使用することで、コンポーネント内で簡潔にテーマにアクセスできるようになります。

ステップ 4: ルートレイアウトへの適用

Next.js のルートレイアウトに ThemeProvider を適用します。 これにより、アプリケーション全体でテーマ機能が利用可能になります。

typescript// app/layout.tsx

import { ThemeProvider } from '@/contexts/ThemeContext';
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <ThemeProvider defaultTheme='brand-a'>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

ルートレイアウトで ThemeProvider を配置することで、すべてのページコンポーネントでテーマ機能が使えるようになります。

ステップ 5: コンポーネントでの利用

実際のコンポーネントで、定義した色を使用してみましょう。 ここでは、ボタンコンポーネントとテーマ切り替えコンポーネントを作成します。

まず、基本的なボタンコンポーネントの例です。

typescript// components/Button.tsx

import { ButtonHTMLAttributes } from 'react';

interface ButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  children: React.ReactNode;
}

export function Button({
  variant = 'primary',
  children,
  className = '',
  ...props
}: ButtonProps) {
  // variant に応じたクラス名を設定
  const variantClasses = {
    primary: 'bg-primary hover:bg-primary/90 text-white',
    secondary:
      'bg-secondary hover:bg-secondary/90 text-white',
    danger: 'bg-danger hover:bg-danger/90 text-white',
  };

  return (
    <button
      className={`
        px-4 py-2 rounded-lg font-medium transition-colors
        ${variantClasses[variant]}
        ${className}
      `}
      {...props}
    >
      {children}
    </button>
  );
}

このボタンコンポーネントは、bg-primary などの Tailwind クラスを使用しています。 テーマが切り替わると、これらのクラスが参照する CSS 変数の値が変わり、ボタンの色も自動的に変更されるのです。

次に、テーマを切り替えるためのコンポーネントを作成します。

typescript// components/ThemeSwitcher.tsx

'use client';

import { useTheme } from '@/contexts/ThemeContext';
import type { Theme } from '@/types/theme';

export function ThemeSwitcher() {
  const { theme, setTheme } = useTheme();

  const themes: { value: Theme; label: string }[] = [
    { value: 'brand-a', label: 'ブランド A' },
    { value: 'brand-b', label: 'ブランド B' },
    { value: 'brand-c', label: 'ブランド C' },
  ];

  return (
    <div className='flex gap-2 p-4 bg-surface rounded-lg'>
      <span className='text-primary font-medium'>
        テーマ選択:
      </span>
      {themes.map((t) => (
        <button
          key={t.value}
          onClick={() => setTheme(t.value)}
          className={`
            px-3 py-1 rounded transition-colors
            ${
              theme === t.value
                ? 'bg-primary text-white'
                : 'bg-background text-secondary hover:bg-surface'
            }
          `}
        >
          {t.label}
        </button>
      ))}
    </div>
  );
}

このコンポーネントでは、useTheme フックを使用して現在のテーマを取得し、ボタンクリックで setTheme を呼び出してテーマを変更します。 選択中のテーマには bg-primary クラスが適用され、ブランドカラーで強調表示されるのです。

ステップ 6: 実用的なコンポーネント例

より実践的な例として、カードコンポーネントとダッシュボードページを作成してみましょう。

まず、カードコンポーネントです。

typescript// components/Card.tsx

import { ReactNode } from 'react';

interface CardProps {
  title: string;
  description: string;
  status?: 'success' | 'warning' | 'danger';
  children?: ReactNode;
}

export function Card({
  title,
  description,
  status,
  children,
}: CardProps) {
  // ステータスに応じたアクセントカラーを設定
  const statusColors = {
    success: 'border-l-success',
    warning: 'border-l-warning',
    danger: 'border-l-danger',
  };

  const borderClass = status
    ? statusColors[status]
    : 'border-l-primary';

  return (
    <div
      className={`
      bg-surface border-l-4 ${borderClass}
      p-6 rounded-lg shadow-sm
    `}
    >
      <h3 className='text-xl font-bold text-primary mb-2'>
        {title}
      </h3>
      <p className='text-secondary mb-4'>{description}</p>
      {children}
    </div>
  );
}

カードコンポーネントでは、左側のボーダーカラーをステータスに応じて変更しています。 テーマが切り替わると、これらの色も自動的に更新されます。

次に、これらのコンポーネントを組み合わせたダッシュボードページの例です。

typescript// app/dashboard/page.tsx

'use client';

import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Card } from '@/components/Card';
import { Button } from '@/components/Button';

export default function DashboardPage() {
  return (
    <div className='min-h-screen bg-background p-8'>
      <div className='max-w-6xl mx-auto space-y-6'>
        {/* ヘッダー部分 */}
        <header className='flex justify-between items-center'>
          <h1 className='text-3xl font-bold text-primary'>
            マルチブランドダッシュボード
          </h1>
          <ThemeSwitcher />
        </header>

        {/* メインコンテンツ */}
        <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'>
          <Card
            title='アクティブユーザー'
            description='現在ログイン中のユーザー数'
            status='success'
          >
            <p className='text-4xl font-bold text-primary'>
              1,234
            </p>
          </Card>

          <Card
            title='処理待ちタスク'
            description='対応が必要な項目'
            status='warning'
          >
            <p className='text-4xl font-bold text-primary'>
              23
            </p>
          </Card>

          <Card
            title='エラー件数'
            description='過去 24 時間のエラー'
            status='danger'
          >
            <p className='text-4xl font-bold text-primary'>
              5
            </p>
          </Card>
        </div>

        {/* アクションボタン */}
        <div className='flex gap-4'>
          <Button variant='primary'>レポート作成</Button>
          <Button variant='secondary'>
            データエクスポート
          </Button>
          <Button variant='danger'>緊急停止</Button>
        </div>
      </div>
    </div>
  );
}

このダッシュボードでは、テーマ切り替え機能と様々なコンポーネントを組み合わせています。 ThemeSwitcher でテーマを変更すると、カード、ボタン、テキストなど、すべての要素が一斉に新しいブランドカラーに切り替わるのです。

ステップ 7: ローカルストレージとの連携

ユーザーが選択したテーマを永続化するために、ローカルストレージと連携する機能を追加しましょう。 これにより、ページをリロードしても選択したテーマが保持されます。

typescript// contexts/ThemeContext.tsx の更新版

'use client';

import {
  createContext,
  useContext,
  useState,
  useEffect,
  ReactNode,
} from 'react';
import type {
  Theme,
  ThemeContextType,
} from '@/types/theme';

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

// ローカルストレージのキー名
const THEME_STORAGE_KEY = 'app-theme';

interface ThemeProviderProps {
  children: ReactNode;
  defaultTheme?: Theme;
}

export function ThemeProvider({
  children,
  defaultTheme = 'brand-a',
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>(defaultTheme);
  const [isInitialized, setIsInitialized] = useState(false);

  // 初回レンダリング時にローカルストレージからテーマを読み込む
  useEffect(() => {
    const savedTheme = localStorage.getItem(
      THEME_STORAGE_KEY
    ) as Theme | null;
    if (savedTheme) {
      setTheme(savedTheme);
    }
    setIsInitialized(true);
  }, []);

  // テーマ変更時に HTML 要素とローカルストレージを更新
  useEffect(() => {
    if (isInitialized) {
      document.documentElement.setAttribute(
        'data-theme',
        theme
      );
      localStorage.setItem(THEME_STORAGE_KEY, theme);
    }
  }, [theme, isInitialized]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);

  if (context === undefined) {
    throw new Error(
      'useTheme must be used within a ThemeProvider'
    );
  }

  return context;
}

この実装では、isInitialized ステートを使用して、初回のローカルストレージ読み込みが完了してから DOM 操作を行うようにしています。 これにより、不要な再レンダリングを防ぎ、パフォーマンスを最適化できるのです。

ステップ 8: サーバーサイドでのテーマ対応

Next.js の App Router では、サーバーコンポーネントでもテーマに対応したい場合があります。 クッキーを使用してテーマ情報を保持し、サーバーサイドで読み取る方法を実装しましょう。

まず、テーマをクッキーに保存する機能を追加します。

typescript// utils/theme.ts

import { cookies } from 'next/headers';
import type { Theme } from '@/types/theme';

export const THEME_COOKIE_NAME = 'app-theme';

// サーバーサイドでテーマを取得
export async function getServerTheme(): Promise<Theme> {
  const cookieStore = await cookies();
  const theme = cookieStore.get(THEME_COOKIE_NAME)
    ?.value as Theme | undefined;
  return theme || 'brand-a';
}

// クライアントサイドでテーマをクッキーに保存
export function setThemeCookie(theme: Theme) {
  document.cookie = `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=31536000`;
}

クッキーを使用することで、サーバーサイドでも初回レンダリング時からテーマを適用できます。

次に、ThemeProvider を更新してクッキーにも保存するようにします。

typescript// contexts/ThemeContext.tsx の setTheme 部分を更新

import { setThemeCookie } from '@/utils/theme';

// ThemeProvider 内の setTheme をラップ
const handleSetTheme = (newTheme: Theme) => {
  setTheme(newTheme);
  setThemeCookie(newTheme);
};

return (
  <ThemeContext.Provider
    value={{ theme, setTheme: handleSetTheme }}
  >
    {children}
  </ThemeContext.Provider>
);

これにより、クライアントサイドでテーマを変更すると、自動的にクッキーにも保存されます。

高度な活用例

1. レスポンシブなカラーバリエーション

画面サイズに応じて色の濃淡を変更したい場合、Tailwind のレスポンシブ修飾子と組み合わせることができます。

typescript// components/Hero.tsx

export function Hero() {
  return (
    <div
      className='
      bg-primary/10 md:bg-primary/20 lg:bg-primary/30
      p-8 md:p-12 lg:p-16
      rounded-lg
    '
    >
      <h2 className='text-3xl md:text-4xl lg:text-5xl font-bold text-primary'>
        レスポンシブヒーローセクション
      </h2>
      <p className='text-secondary mt-4'>
        画面サイズに応じて背景の濃度が変化します。
      </p>
    </div>
  );
}

2. グラデーションの活用

CSS 変数はグラデーションにも活用できます。

css/* globals.css に追加 */

.gradient-brand {
  background: linear-gradient(
    135deg,
    rgb(var(--color-primary)) 0%,
    rgb(var(--color-secondary)) 100%
  );
}

このクラスを使用すると、テーマに応じたグラデーション背景が作成できます。

typescript// components/GradientCard.tsx

export function GradientCard({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className='gradient-brand p-8 rounded-lg text-white'>
      {children}
    </div>
  );
}

3. ダークモードとの併用

ブランドテーマに加えて、ライト/ダークモードの切り替えも実装できます。

typescript// types/theme.ts に追加

export type ColorMode = 'light' | 'dark';

export interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  colorMode: ColorMode;
  setColorMode: (mode: ColorMode) => void;
}

ThemeContext を拡張して、カラーモードの管理機能を追加します。

typescript// contexts/ThemeContext.tsx の拡張

export function ThemeProvider({
  children,
  defaultTheme = 'brand-a',
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>(defaultTheme);
  const [colorMode, setColorMode] =
    useState<ColorMode>('light');

  useEffect(() => {
    document.documentElement.setAttribute(
      'data-theme',
      theme
    );
    document.documentElement.setAttribute(
      'data-color-mode',
      colorMode
    );
  }, [theme, colorMode]);

  return (
    <ThemeContext.Provider
      value={{ theme, setTheme, colorMode, setColorMode }}
    >
      {children}
    </ThemeContext.Provider>
  );
}

CSS で data-color-mode に応じた変数のオーバーライドを定義すれば、ブランドとカラーモードを独立して切り替えられるようになります。

パフォーマンス最適化

1. CSS 変数のスコープ制限

すべての色を CSS 変数にする必要はありません。 変更の可能性がある色のみを CSS 変数にすることで、ブラウザのレンダリング負荷を軽減できます。

css/* グレースケールは固定値を使用 */
:root {
  /* ブランド固有の色のみ CSS 変数化 */
  --color-primary: 59 130 246;
  --color-secondary: 139 92 246;

  /* グレースケールは Tailwind のデフォルトを使用 */
}

2. 遅延読み込み

テーマ切り替え UI は、必要になるまで読み込まないようにできます。

typescript// app/dashboard/page.tsx

import dynamic from 'next/dynamic';

// ThemeSwitcher を動的インポート
const ThemeSwitcher = dynamic(
  () =>
    import('@/components/ThemeSwitcher').then(
      (mod) => mod.ThemeSwitcher
    ),
  { ssr: false }
);

export default function DashboardPage() {
  return (
    <div>
      <ThemeSwitcher />
      {/* その他のコンテンツ */}
    </div>
  );
}

これにより、初期バンドルサイズを削減し、ページの読み込み速度を向上させられます。

テストの実装

マルチブランド設計のテストも重要です。 以下は、Jest と React Testing Library を使用したテストの例になります。

typescript// __tests__/ThemeContext.test.tsx

import {
  render,
  screen,
  fireEvent,
} from '@testing-library/react';
import {
  ThemeProvider,
  useTheme,
} from '@/contexts/ThemeContext';

function TestComponent() {
  const { theme, setTheme } = useTheme();

  return (
    <div>
      <span data-testid='current-theme'>{theme}</span>
      <button onClick={() => setTheme('brand-b')}>
        ブランド B に変更
      </button>
    </div>
  );
}

describe('ThemeContext', () => {
  it('デフォルトテーマが正しく設定される', () => {
    render(
      <ThemeProvider defaultTheme='brand-a'>
        <TestComponent />
      </ThemeProvider>
    );

    expect(
      screen.getByTestId('current-theme')
    ).toHaveTextContent('brand-a');
  });

  it('テーマを変更できる', () => {
    render(
      <ThemeProvider defaultTheme='brand-a'>
        <TestComponent />
      </ThemeProvider>
    );

    const button = screen.getByText('ブランド B に変更');
    fireEvent.click(button);

    expect(
      screen.getByTestId('current-theme')
    ).toHaveTextContent('brand-b');
  });

  it('data-theme 属性が HTML 要素に設定される', () => {
    render(
      <ThemeProvider defaultTheme='brand-a'>
        <TestComponent />
      </ThemeProvider>
    );

    const button = screen.getByText('ブランド B に変更');
    fireEvent.click(button);

    expect(
      document.documentElement.getAttribute('data-theme')
    ).toBe('brand-b');
  });
});

テストを実装することで、テーマ切り替え機能が期待通りに動作することを保証できます。

まとめ

CSS 変数と data-theme 属性を組み合わせることで、Tailwind CSS でのマルチブランド設計を効率的に実現できました。 この方法には以下のようなメリットがあります。

#メリット詳細
1単一コードベースコンポーネントコードはブランドに依存せず、保守性が大幅に向上します
2実行時の動的切り替えJavaScript でテーマを変更するだけで、即座に UI が更新されます
3型安全性TypeScript による型チェックで、存在しないテーマの指定を防げます
4拡張性新しいブランドの追加は CSS 変数の定義と型の追加だけで済みます
5パフォーマンスCSS 変数のみで実装するため、ビルドサイズやランタイムへの影響が最小限です
6Tailwind との親和性Tailwind の全機能(不透明度、レスポンシブなど)がそのまま利用できます

実装のポイントをまとめると、以下のようになります。

1. CSS 変数の定義

  • RGB 形式で色を定義し、不透明度に対応
  • :root でデフォルト値を設定
  • [data-theme] セレクタでブランドごとに上書き

2. Tailwind 設定

  • rgb(var(--color-name) ​/​ <alpha-value>) 形式で参照
  • 必要な色のみを CSS 変数化し、パフォーマンスを最適化

3. React での実装

  • Context API でテーマ状態を管理
  • useEffectdata-theme 属性を更新
  • ローカルストレージやクッキーで永続化

4. コンポーネント設計

  • ブランド固有の値をハードコードしない
  • Tailwind のユーティリティクラスを活用
  • 型定義で安全性を確保

この設計パターンは、スケーラブルで保守性の高いマルチブランド対応を実現する強力な手法です。 ホワイトレーベルサービスや企業向けカスタマイズ機能など、様々な場面で活用できるでしょう。

ぜひ、あなたのプロジェクトでもこの方法を試してみてください。 最初は少し複雑に感じるかもしれませんが、一度設定してしまえば、新しいブランドの追加や変更が驚くほど簡単になりますよ。

関連リンク