Skip to content
ブログに戻る
チュートリアル

OKLCH 色空間の解説 — Tailwind v4 が採用した理由

なぜ OKLCH は 2024〜2026 年にかけてデザインシステムの標準となったのか。HSL や LCH との違い、そして HEX → OKLCH の変換を実例で解説します。

14 分で読めます

OKLCH 色空間の解説 — Tailwind v4 が採用した理由

2025 年以降のデザインシステム — shadcn/ui、Radix Themes、Tailwind v4 のベースパレット — のソースを開けば、まず目に飛び込んでくるのは色の表記です。hex コードでも hsl() の三つ組でもなく、3 年前には誰も話題にしなかった関数、oklch()。Tailwind v4 はデフォルトパレット全体を OKLCH リテラルで出荷します。shadcn は OKLCH カスタムプロパティを発行するテーマを生成するようになりました。Vercel のデザインシステムは 2024 年に OKLCH を中心に再構築されました。

これは単なる流行ではありません。本格的なデザインシステムが密かに色モデルを切り替え続けているのには、具体的で数学的な理由があります。一度その理由が見えてしまうと、HSL が私たちの使い方には常に間違っていたことに気づかずにはいられません。

この記事では、その理由を第一原理から順を追って解説し、hex コードから OKLCH への実際の変換を示し、自分のパレット向けの移行レシピを提示します。

色空間が破綻したとき

デザインシステムにはトーン上の役割があります。ボタンはホバー時に通常状態より少し明るくなります。ミュート系のカードは周囲のサーフェスより 1 段だけ暗く沈みます。フォーカスリングは背後の中立的なクロームよりはっきりと明るく見える必要があります。これを規模を持って正しくやるには、「より明るい」「より暗い」がパレット内のすべての色相にわたって同じ意味を持たねばなりません。

その要件は、パレットが 8 色・3 状態くらいで済んでいた頃なら無視できました。チームが 11 段ランプ(Tailwind 流儀の 50〜950)、8 つのセマンティックカラー、ライトとダークのバリアント、そして iOS・Android・Web のシステムカラーと共存させるブランドアクセントを出荷し始めると、無視できなくなりました。「この teal-500 は私たちの blue-500 と同じ明るさなのか?」という問いは、芸術監督的な贅沢ではなく、現実のエンジニアリング問題になったのです。

HSL — CSS 3 以来の主力モデル — はそれに答えられませんでした。同じ L 値を持つ 2 つの HSL カラーは、知覚上の明るさがまったく違って見えることがあります。lightness: 50% の純粋な HSL イエローは、同じ lightness の純粋な HSL ブルーよりはるかに明るく見えます。私たちの目はイエローとブルーを同等には知覚しません。HSL はピッカー上の直感性を狙って設計されたのであって、ランプにおける知覚的な一貫性のためではないのです。2023 年までに、片手で数えられない色数を超えてスケールしたデザインシステムはどれも、カスタムの混色スクリプトや手作業のオーバーライドでこの問題を回避していました。

私たちが必要としていたのは、L が本当に「人間が報告するであろう知覚的明度」を意味し、色相を回転させても彩度を落としても明るさが副作用としてこっそり変わらない色モデルでした。そうしたモデルは学術的な色科学の世界には存在していました — ただ、CSS にはまだ届いていなかっただけです。

HSL の問題を具体的に

これらをブラウザに入れて並べて見てください:

.a { background: hsl(60 100% 50%); }   /* yellow */
.b { background: hsl(240 100% 50%); }  /* blue */
.c { background: hsl(120 100% 50%); }  /* green */

3 つとも L: 50% です。同じ明るさには見えません。イエローはほとんど目を焼くようですし、ブルーは白い背景に対してほぼ黒く読めます。グリーンはその中間です。L に 10% を足してホバー状態を作ろうとすると、イエローのホバーは辛うじて見える程度なのに対して、ブルーのホバーは劇的な変化になります。インタラクションの仕上がりが、デザイナーがたまたまどの色相から始めたかに依存してしまうわけです。

これは HSL のバグではありません。HSL は 1978 年に塗り絵的なカラーピッカー向けに設計されました。ユーザーが hue、saturation、そして (max(R,G,B) + min(R,G,B)) / 2 と定義された「lightness」を操作して色を合わせる、というモデルです。この数式に人間の知覚という概念はありません。HSL の lightness は sRGB チャネルの幾何学的中点にすぎないのです。

CIE — 測色学の国際標準化団体 — はこの問題を 1970 年代から把握していました。彼らは知覚的に均一な 2 つの空間、CIELAB と CIELUV を発表し、lightness を人間の視覚が実際に行うふるまいに近いものとして定義しました。1990 年代までに CIE LAB は印刷、写真、カラーマネジメントの標準となりました。しかし RGB への変換は厄介で、CSS は広く採用しませんでした。Web 開発者は HSL を使い続けました — 正しかったからではなく、そこにあったからです。

CIE LAB / LCH: 学術的な解、その独自の問題

CIELAB は XYZ 三刺激値(人間の錐体が光にどう応答するかのモデル)を取り、立方根と 2 次元回転に通して 3 つのチャネル — L*(lightness、0〜100)、a*(緑 ↔ 赤)、b*(青 ↔ 黄) — を生成します。LCH は同じ空間を極座標で表現したもので、L*C*(chroma、無彩色からの距離)、H*(色相角)です。

これらの空間は計測可能な意味で 知覚的に均一 です。ΔE が 1 — LAB 空間における任意方向の単位ステップ — は、訓練された観察者が検出できる最小の色差におおむね対応します。印刷やプリプレスのワークフローは LAB と LCH の上で何十年も動いてきました。

ではなぜ CSS は LCH をそのまま採用して話を済ませなかったのでしょうか?

理由は 2 つあります。まず、CIE LAB は表面反射に最適化された特定の観察条件(D50 照明下の 2° 標準観察者)に対して較正されたものであり、発光ディスプレイ向けではありません。スクリーンでは知覚的均一性が漂います — LAB 上で「同じ明るさ」の色が、スマホでは常に同じ明るさに見えるとは限らないのです。次に、LCH の色域は扱いにくいものです。LAB がうまく記述できる可視色のうち、一般的なディスプレイ色域の外に出てしまうものがあり、LCH から sRGB へのマッピングでは色相シフトが時折起こります(chroma を下げると青がわずかに紫に寄る、など)。デザインシステムの仕事には、どちらも致命的です。

CSS Color 4 は 2021 年に lab()lch() を追加し、モダンブラウザで実際に動きます。しかし、発光スクリーン上で一貫したトーンランプを構築するという固有の課題については、コミュニティは探し続けたのでした。

OKLAB / OKLCH: Ottosson の 2020 年の洞察

2020 年 12 月、スウェーデンのカラーエンジニアである Björn Ottosson は「A perceptual color space for image processing」と題する論文を公開しました。論文は小さなものでした — 3 つの短い行列、立方根のステップ、較正テーブルなし、著作権で守られた参照データなし。Ottosson は既存の IPT と CAM16-UCS の色モデル — 良い性質を持つが数式が悪い学術的空間 — を取り、それらの知覚的ふるまいを近似するシンプルな空間を、線形光の XYZ 三刺激値に対する通常の行列乗算で導出しました。

彼はそれを OKLAB と呼びました。極形式は OKLCH です。

OKLCH を特別にしているのは新規性ではなく、目的に合っている ことです。3 つの性質が組み合わさっています:

  1. OKLCH の lightness は本物の知覚的明度です。 L: 0.7 の純粋なイエローと L: 0.7 の純粋なブルーは、較正されたディスプレイで同じ明るさに見えます。L + 0.05 として定義されたホバー状態は、パレット全体で視覚的に等価なシフトを生みます。
  2. Chroma が変わっても色相が保たれます。 oklch(0.7 0.2 30) の C を oklch(0.7 0.1 30) に下げても、色相はそのまま残ります。LCH で同じ操作を行うと、見える色相シフトが入りがちです。OKLCH では、ブランドカラーのミュートなバリアントを作るために chroma を平らにしても、誤って別の色相に漂うことがありません。
  3. 数式が安価です。 行列乗算 2 回と立方根 1 回。30 行の JavaScript 関数で実装できます。ルックアップテーブル不要、デバイスごとの較正不要、ライセンスの懸念なし。

この組み合わせこそが OKLCH を実用的な CSS で使えるものにしました。W3C は 2022 年に CSS Color 4 へ oklch() を追加しました。Chrome 111 は 2023 年に出荷しました。2024 年中盤までにエバーグリーンブラウザはすべてサポートしました。Tailwind v4 は同じ年にこれをデフォルトパレット形式にしました。

数式: HEX → OKLCH の変換を実例で

Tailwind の blue-500 である #3b82f6 を OKLCH に変換してみましょう。これは カラーコンバーターツールHEX から OKLCH へのスポーク がキー入力ごとに走らせているのと同じ数式です。内部で何が起きているかを知っておくと、次のセクションで触れる注意点の意味がはっきりします。

ステップ 1: hex から sRGB へ。 6 桁の hex を 2 桁ずつ 3 ペアに分け、それぞれを 255 で割ります。

const r = 0x3b / 255; // 0.231
const g = 0x82 / 255; // 0.510
const b = 0xf6 / 255; // 0.965

これらは ガンマ符号化された sRGB 値です — 画像ファイルが保持するチャネル値で、モニターの発光特性を補償するための非線形カーブが焼き込まれています。

ステップ 2: sRGB から線形 sRGB へ。 ガンマカーブを取り除いて線形光のチャネル値を得ます。CSS Color 4 §11.2 の標準的な区分的変換は次のとおりです:

const linear = (v) => v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
const [lr, lg, lb] = [r, g, b].map(linear);
// lr ≈ 0.045, lg ≈ 0.220, lb ≈ 0.923

ステップ 3: 線形 sRGB から XYZ D65 へ。 CSS Color 4 §15.1 で定義された標準的な行列乗算:

const x = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
const y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb;
const z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb;
// x ≈ 0.265, y ≈ 0.231, z ≈ 0.927

これは XYZ 三刺激値の正規表現 — 「この色は人間の錐体応答という観点でどんな波長なのか」という形式 — です。

ステップ 4: XYZ から LMS へ。 Ottosson の第 1 行列が XYZ を OKLAB 用に調整された long / medium / short の錐体基本量空間に写します:

const lms = [
  0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z,
  0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z,
  0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z,
];

ステップ 5: LMS 値の立方根。 これが知覚的圧縮ステップです — CIE LAB の立方根に対応します:

const lms_ = lms.map(Math.cbrt);

ステップ 6: LMS’ から OKLAB へ。 Ottosson の第 2 行列:

const L = 0.2104542553 * lms_[0] + 0.7936177850 * lms_[1] - 0.0040720468 * lms_[2];
const a = 1.9779984951 * lms_[0] - 2.4285922050 * lms_[1] + 0.4505937099 * lms_[2];
const b_ = 0.0259040371 * lms_[0] + 0.7827717662 * lms_[1] - 0.8086757660 * lms_[2];
// L ≈ 0.629, a ≈ -0.022, b_ ≈ -0.191

これが OKLAB です。lightness チャネルの L ≈ 0.629 は、このブルー特有の「60% の明るさ」として目が知覚する値です。

ステップ 7: OKLAB から OKLCH(直交座標から極座標)。

const C = Math.sqrt(a * a + b_ * b_);           // 0.193
const H = (Math.atan2(b_, a) * 180 / Math.PI + 360) % 360; // 263.4
// → oklch(0.629 0.193 263.4)

つまり #3b82f6oklch(0.629 0.193 263.4) です。lightness 0.629、chroma 0.193、色相 263.4°。

この色から、C と H を固定して L だけを変えて 50〜950 ランプを構築すると(0.95 から 0.15 まで 11 ステップ)、どの段でも色相が同じに見え、lightness が均一にフェードしていくパレットが得られます。同じことを HSL でやると、暗い段は紫に寄り、明るい段は灰色に向かいます。これ が勝利点なのです。

Display P3 + Rec2020: OKLCH が広色域を解放する理由

OKLCH には上限がありません。HSL(sRGB に上限あり)や LCH(反射面に較正済み)と違って、OKLCH には暗黙の色域がありません。oklch(0.7 0.25 30) と書けば、Display P3 の色立体には収まるが sRGB には収まらない鮮やかな赤を作り出せます。最近の iPhone や MacBook ではそのままレンダリングされます。古いモニターでは、ブラウザが最寄りの sRGB 表現に自動でスナップします。

これが大事なのは、Apple、Samsung、W3C のすべてが 2010 年代後半に広色域ハードウェアを出荷していたからです。mini-LED 搭載の MacBook Pro 14” / 16” はデフォルトで P3 を出荷します。iPhone 15 Pro は Safari で Display P3 をレンダリングします。Android のフラッグシップは Rec2020 パネルを搭載します。2025 年までに、デザインシステムのトラフィックのかなりの割合が、HSL/sRGB が表現できない色を表示できる広色域ハードウェア上で動くようになりました。

OKLCH ならそうした色を、別途 @media (color-gamut: p3) 宣言を組み込まなくても書けます。ブラウザがフォールバックを処理してくれます。デザインシステムは「デバイスが描ける最も鮮やかな赤を使う」という挙動を箱を開けた時点で得られるわけです。

これが OKLCH がデザイントークンに正しい形式である理由でもあります。OKLCH で書かれた --brand 変数は、意図についての デバイス非依存 な記述です。ブラウザはユーザーが持っているディスプレイで何を描くかを決めてくれますし、コードは CSS、SwiftUI(Display P3 Color をネイティブサポート)、Android Compose(Rec2020 対応)、Flutter にまたがって持ち運べます。

Tailwind v4 とデザイントークン革命

2024 年にリリースされた Tailwind v4 は、OKLCH を研究領域から業界デフォルトへ転じさせた変曲点でした。Tailwind の作者たちは 3 つの強い決断を下しました:

  1. デフォルトパレットは OKLCH。 slate、gray、zinc、neutral、stone — Tailwind の色はすべてソース上で oklch() で定義されています。50〜950 ランプは構成上、知覚的 lightness が均一です。
  2. カスタムテーマは OKLCH リテラルを含む @theme ブロックで定義する。 ブランドカラーは oklch() トークンとして定義され、下流のユーティリティ(bg-brand-500text-brand-300)が生成されます。
  3. フォールバックの儀式は不要。 OKLCH をサポートしないブラウザは Tailwind v4 が定めるベースラインを下回ります。

その最後の決断こそが、採用を地雷のない選択にしたものです。2023 年の段階ではまだ、デザイナーは古い Safari が壊れないように、すべての色について oklch()hsl() の両バージョンを出荷していました。Tailwind v4 ではベースラインが 2023 年以降のブラウザに引き上げられ、OKLCH はどこでも動きます。

shadcn/ui のテーマジェネレーターは同じパターンに従います: ブランドカラーを入力すれば、OKLCH ランプが返ってきます。Vercel のデザインシステムはセマンティックカラーに OKLCH を使います。Radix Themes のカラースケールも OKLCH で定義されています。コミュニティは収束したのです。

実践的な移行: HEX パレット → OKLCH パレット

現在 hex ベースのパレットを持っているなら、移行は機械的に進められます。次のレシピをどうぞ:

1. ランプの構造を決める。 Tailwind の 50〜950(11 ストップ)は事実上のデフォルトで、特別な理由がなければ追従する価値があります。L = 0.97, 0.93, 0.86, 0.76, 0.63, 0.50, 0.42, 0.34, 0.26, 0.18, 0.10 のストップでスムーズな知覚的ランプが得られます。

2. ブランドの hex を OKLCH に変換する。 カラーコンバーターHEX から OKLCH へ ツールを使ってください。oklch(0.629 0.193 263.4) のような 3 つ組が得られます。H の値に注目 — これがブランドの色相です。

3. C と H を一定に保ち、L だけを変える。 次のように発行してランプを構築します:

--brand-50:  oklch(0.97 0.193 263.4);
--brand-100: oklch(0.93 0.193 263.4);
...
--brand-950: oklch(0.10 0.193 263.4);

4. 極端なストップを調整する。 ごく低い L(≤ 0.20)とごく高い L(≥ 0.95)では、高い chroma 値は sRGB を外れます。それらのストップでは C を下げるか、ブラウザの自動スナップを受け入れてください。Tailwind のデフォルトは両端に向けて chroma を下げています — そのパターンをコピーすると良いでしょう。

5. セマンティックエイリアスを定義する。 --surface: var(--brand-50)--surface-elevated: var(--brand-100)--text-primary: var(--brand-900) など。こうしてデザイントークンは色ではなく意図として読めるようになります。

6. コントラストを検証する。 APCA Lc または WCAG 2 コントラスト比を使って、--text-*--surface-* の各ペアがアクセシビリティの基準を満たすかを確かめてください。OKLCH の L は知覚的なので、コントラストの数式は HSL でやるよりも信頼できます。

60 色のレガシーパレットでこの移行を走らせたチームは、たいてい 1 つの午後で 30〜40 トークンの、より小さく均一な OKLCH パレットに着地します。新パレットはより小さく出荷され、より小さな CSS を生み、追加のチューニングなしでホバーやディセーブル状態のトーン推移が見るからに良くなります。

落とし穴とその対処

押さえておくべき点をいくつか:

色域外の警告。 一部の OKLCH 値は Display P3 や sRGB の外に出ます。モダンブラウザは最寄りの有効な色へ自動でスナップしてくれますが、スナップは損失を伴います: あなたの oklch(0.7 0.25 30) は書いたときよりわずかに彩度が下がってレンダリングされるかもしれません。カラーコンバーターの色域行 のようなツールは、その色が sRGB セーフか、P3 セーフか、Rec2020 セーフかを示し、ワンクリックで sRGB へスナップできるので、書いたものがそのまま見えるようになります。

サブピクセル chroma の癖。 OKLCH の chroma は無制限ですが、可視色における 実用的な レンジはおおむね 0 から ~0.4 です。0.4 を超える値はレーザー光のような単色光でのみ達成可能で、ディスプレイがレンダリングできる物理的な色ではありません。カラーコンバーターが chroma スライダーを 0.4 で頭打ちにしているのはこのためです。それを超える値はどんな実機ディスプレイでも知覚できる差を生みません。

ブラウザサポート、2025 年スタイル。 Chrome 111+、Safari 15.4+、Firefox 113+ はすべて oklch() をネイティブサポートします。2023 年以前のブラウザはサポートしません。レガシー IE/Edge や古いモバイル Safari(オーディエンス次第で 1〜3% のトラフィック)をサポートする必要があるなら、@supports (color: oklch(0 0 0)) で OKLCH 宣言と hex フォールバックをペアにできますが、2025 年に出荷するデザインシステムトークンにおいては、フォールバックのコストがレガシー側の利得を上回ることがよくあります。

hex コードの永続性。 OKLCH はデザインシステムの 意図 のためのものです。CMS はレガシー上の理由(メール署名、Office ドキュメント、ブランドアセットのチェックリスト)で依然 hex を必要とすることがあります。各 OKLCH トークンに対して sRGB スナップ済み hex を吐き出す自動生成のルックアップテーブルを保ちつつ、オーサリングは hex ではしないでください。

OKLCH と OKLAB を混同しない。 OKLAB は直交形式(L, a, b チャネル)、OKLCH は同じ色空間の極形式(L, C, H)です。両者は直交 ↔ 極座標の 1 ステップで相互変換できます。トークンには OKLCH(読みやすく、ランプを作りやすい)、内部で色を補間したり混色したりするなら OKLAB を使ってください。

自分のパレットで試してみる

ここで述べたことを実際の挙動として見る最速の方法は、ブランドの hex を カラーコンバーター に放り込んでみることです。HEX フィールドにブランドカラーを入力し、OKLCH 出力を読み取ってください。それから OKLCH 側のスライダーを動かして、chroma を下げても同じ色相が同じ色相のままであること、色相を回しても同じ lightness が同じ lightness のままであることを観察してみてください。数分も触れば、なぜ HSL がトーンランプにとって常に間違った道具だったのか、なぜ本格的なデザインシステムがすべて移行したのかが、直感的に分かるはずです。

特定の HEX から OKLCH への変換には HEX から OKLCH へ を使ってください — この記事と同じ数式で、加えてもう 1 つの利点があります: 色域分類(sRGB / Display P3 / Rec2020)を示してくれるので、どのブランドカラーがどこでも安全か、逆にどの色を完全にレンダリングするには広色域デバイスが必要かが分かります。

これが OKLCH です。移行する価値があります。うまくやれば、もう二度と hsl() を書くことはないでしょう。