メインコンテンツまでスキップ
  • Created:
  • Updated:
  • Author:
    Takeshi Takatsudo

3層カラー戦略

問題

ウェブサイトやウェブアプリを構築する際、コンポーネントの CSS にカラー値を直接記述しがちです。ボタンに hex コードを指定し、サイドバーに別のシェードを使い、hover 状態にはさらに別の色を使います。最初はこれで問題ありませんが、2つの深刻な問題が生じます。

  1. ブランドカラーの変更にはすべてのコンポーネントを探す必要がある — 一箇所で更新できる場所がない
  2. セマンティックな意味がない — ボタンで #89b4fa を見ても、なぜその色が選ばれたのか、どんな役割を果たしているのかわからない

よくある改善策として、CSS カスタムプロパティのパレットを定義する方法があります(--color-blue-500--color-red-400)。しかしパレットがあっても、コンポーネントは特定のパレットカラーに密結合してしまいます。デザインシステムが「主要なインタラクティブ要素」をブルーからインディゴに変更すると決めた場合、結局すべてのコンポーネントを検索し直すことになります。

解決方法

カラーを**3つの層(ティア)**に整理し、それぞれに明確な目的を持たせます。

ティア名前目的
1パレット生のカラー値 — 利用可能なすべての色--palette-blue-500
2テーマセマンティックな役割 — デザインにおける各色の意味--theme-fg, --theme-accent
3コンポーネントスコープ付きオーバーライド — 1つのコンポーネント固有の色--button-shadow, --card-highlight

重要なポイントは、各層はその上の層のみを参照するということです。コンポーネントはテーマトークンを使います。テーマトークンはパレットカラーを指します。パレットは実際の値を保持します。

コード例

ティア 1: パレット

パレットは素材そのものです。システムで利用可能なすべての色が含まれます。これらはコンポーネントで直接使いません。絵の具のチューブのようなものです。用意はしておきますが、計画なしにキャンバスに直接絞り出すことはしません。

ティア 1 — パレット: 生のカラー値

ティア 2: テーマ

テーマトークンはパレットカラーにセマンティックな意味を与えます。「blue-500」の代わりに、コンポーネントは「アクセントカラー」や「前景色」を参照します。この層がリデザインを容易にします。--theme-accent をブルーからインディゴに一箇所で変更するだけで、すべてのコンポーネントが更新されます。

ティア 2 — テーマ: パレットからのセマンティックマッピング

ティア 3: コンポーネントスコープのカラー

コンポーネントによっては、グローバルテーマに収まらない色が必要になることがあります。シャドウ、プロダクトのバリエーションカラー、微妙なグラデーションのストップなどです。これらがティア 3 の変数です。スコープが狭く、コンポーネント自体で定義され、テーマトークンまたはパレットトークンを参照します。

ティア 3 — コンポーネント: スコープ付きカラーオーバーライド

3つのティアの連携

ここでは、3つのティアがどのように連携するかを示す完全な例を紹介します。任意の色をその出所まで簡単に追跡できることに注目してください。コンポーネントはテーマを使い、テーマはパレットを指します。

3つのティアすべて — CSS カスタムプロパティの完全な例

ティア 2 の威力: テーマの切り替え

3層システムの最大の利点は、テーマを変更するときに明らかになります。ティア 2 のポインタを再マッピングするだけで、コンポーネントの CSS に一切触れることなく、すべてのコンポーネントが更新されます。

同じコンポーネント、異なるテーマ — ティア 2 のみ変更

完全な CSS コード構造

実際のプロジェクトでは、3つのティアをファイルごとに以下のように整理します。

/* ===== tokens/palette.css — Tier 1 ===== */
:root {
--palette-blue-100: oklch(92% 0.06 250);
--palette-blue-300: oklch(74% 0.14 250);
--palette-blue-500: oklch(58% 0.2 250);
--palette-blue-700: oklch(42% 0.16 250);
--palette-blue-900: oklch(28% 0.1 250);

--palette-red-500: oklch(58% 0.22 25);
--palette-green-500: oklch(58% 0.18 150);
--palette-amber-500: oklch(62% 0.18 65);

--palette-gray-50: oklch(98% 0.003 264);
--palette-gray-100: oklch(96% 0.005 264);
--palette-gray-300: oklch(82% 0.01 264);
--palette-gray-500: oklch(58% 0.01 264);
--palette-gray-700: oklch(40% 0.015 264);
--palette-gray-900: oklch(22% 0.015 264);
}

/* ===== tokens/theme.css — Tier 2 ===== */
:root {
/* Layout */
--theme-fg: var(--palette-gray-900);
--theme-bg: var(--palette-gray-50);
--theme-surface: oklch(100% 0 0);
--theme-border: var(--palette-gray-300);
--theme-muted: var(--palette-gray-500);

/* Interactive */
--theme-accent: var(--palette-blue-500);
--theme-accent-hover: var(--palette-blue-700);
--theme-accent-subtle: var(--palette-blue-100);
--theme-accent-fg: oklch(98% 0.01 250);

/* Feedback */
--theme-error: var(--palette-red-500);
--theme-success: var(--palette-green-500);
--theme-warning: var(--palette-amber-500);
}

/* ===== components/button.css — Tier 3 ===== */
.btn {
/* Component-specific color derived from theme.
Relative color syntax (oklch(from ...)) — Baseline 2024.
For wider support, use a hardcoded fallback. */
--btn-shadow: oklch(from var(--theme-accent) l c h / 0.3);

background: var(--theme-accent);
color: var(--theme-accent-fg);
box-shadow: 0 2px 8px var(--btn-shadow);
}
.btn:hover {
background: var(--theme-accent-hover);
}

/* ===== components/product-card.css — Tier 3 ===== */
.product-card {
/* Colors unique to this component, not in theme */
--card-variant: var(--palette-blue-500);
--card-glow: oklch(from var(--card-variant) l c h / 0.15);
}
.product-card--sunset {
--card-variant: oklch(62% 0.2 50);
}

Tailwind CSS: カスタムテーマ設定による3層構成

3層戦略は Tailwind の設定に自然にマッピングできます。パレットカラーは theme.colors に配置し、テーマトークンは Tailwind クラスが参照する CSS カスタムプロパティにします。

Tailwind: 3層カラーシステム

Tailwind: ティア 2 によるテーマ切り替え

同じ Tailwind マークアップが、まったく異なるカラースキームで動作します。CSS 変数を変更するだけです。

Tailwind: 同じマークアップ、3つの異なるテーマ

ダークモード: もう一つのティア 2 マッピング

ダークモードは3層システムに自然に適合します。パレット(ティア 1)はそのままです。すべての色はすでに揃っています。ダークモードは、同じセマンティックトークンに対して異なるパレット値を選択する、もう一つのティア 2 マッピングにすぎません。CSS の light-dark() 関数を使うと、両方の値を1つの宣言にまとめられるため、特にすっきりと記述できます。

ダークモードのテクニックについて詳しくは、ダークモード戦略を参照してください。

light-dark() によるティア 2 でのダークモード

light-dark() を使えば、同じティア 2 マッピングをセレクタを分けずに記述できます。

/* Tier 2 with light-dark() — both modes in a single declaration */
:root {
color-scheme: light dark;

--theme-bg: light-dark(var(--palette-gray-50), oklch(15% 0.01 264));
--theme-surface: light-dark(oklch(100% 0 0), oklch(20% 0.015 264));
--theme-fg: light-dark(var(--palette-gray-900), oklch(92% 0.005 264));
--theme-muted: light-dark(var(--palette-gray-500), oklch(60% 0.01 264));
--theme-border: light-dark(var(--palette-gray-300), oklch(30% 0.015 264));
--theme-accent: light-dark(var(--palette-blue-500), oklch(70% 0.17 250));
--theme-accent-fg: light-dark(oklch(98% 0.01 250), oklch(15% 0.01 250));
}

ティア 1 とティア 3 はまったく同じままです。変わるのはティア 2 だけです。コンポーネントはライトモードなのかダークモードなのかを知る必要がありません。

AI がよくやるミス

  • ティア 2 をスキップする — パレットカラーをコンポーネントで直接使う(color: var(--palette-blue-500))と目的が台無しになります。ブランドが変わったとき、すべてのコンポーネントを更新しなければなりません
  • ティア 3 の変数が多すぎる — コンポーネントが 10 個以上のローカルカラー変数を定義している場合、テーマ層を再発明している可能性があります。ティア 2 に昇格させましょう
  • パレットとテーマを分離していない--primary: oklch(58% 0.2 250) のように定義すると、生の値とセマンティックな意味が混在し、パレットの交換が不可能になります
  • 命名の不統一 — 同じ層で --color-primary--brand-blue--accent を混在させると混乱を招きます。ティアごとに一貫したプレフィックスを使いましょう(--palette-*--theme-*
  • ティア 1 が小さすぎる — パレットに 5 色しかないと、コンポーネントが独自の生の値を発明せざるを得なくなり(ハードコードされた値のティア 3 カラー)、システムが崩壊します
  • Tailwind ユーティリティにカラーをハードコードするbg-blue-500bg-theme-accent の代わりに使うと、テーマ層を完全にバイパスしてしまいます

使い分け

  • コンポーネントが数個以上あるプロジェクト — 一貫性が必要になった時点で、3層のオーバーヘッドは元が取れます
  • マルチテーマまたはホワイトラベル製品 — ティア 2 によりテーマの切り替えが容易になります
  • デザインシステムやコンポーネントライブラリ — コンポーネントは生の色ではなくテーマトークンを参照すべきです
  • ダークモード — ダークモードはもう一つのティア 2 マッピングにすぎません(ダークモード戦略を参照)
  • 段階的な導入 — ティア 1 + 2 から始めて、コンポーネントにスコープ付きカラーが必要になったらティア 3 を追加できます

3層構成が過剰なケース

  • ブランドカラーが1色でテーマのバリエーションがないシングルページサイト
  • 保守性よりもスピードが重要なクイックプロトタイプ
  • インタラクティブ要素が最小限の静的サイト

関連記事

参考資料