OKLCH でカラーパレットを自動生成していて、黄色だけが思った色にならない。暗くすると泥のような茶色に転び、色相を調整すると今度は白っぽく薄くなる。どちらに振っても正解がない、という状態に陥りました。

原因は OKLCH の欠陥ではなく、ディスプレイが表示できる色の範囲が色相ごとに大きく違うことでした。この記事では、その仕組みと、最終的にどう解決したかをまとめます。


    そもそも「色域(Gamut)」とは何か

    まず前提となる言葉から整理します。

    色域(gamut・ガマット) とは、その機器で表示できる色の範囲のことです。絵の具のセットに喩えると分かりやすいかもしれません。24色セットと48色セットでは、同じ「赤」でも出せる鮮やかさの幅が違います。ディスプレイも同じで、sRGB という一般的な規格では表現できる色に限りがあります。

    重要なのは、この範囲が均等な形をしていないことです。

    「明るい黄色」は作れますが、「暗くて鮮やかな黄色」は作れません。暗くしようとすると必ず茶色やオリーブ色に転びます。一方で青は、暗くしても鮮やかさをかなり保てます。これは人間の目とディスプレイの物理的な性質によるもので、どんなカラーモデルを使っても変えられません。


    OKLCH の3つの値

    OKLCH は色を3つの値で表します。

    記号 意味 範囲
    L 明度(Lightness)。人間が感じる明るさ 0(黒)〜 1(白)
    C 彩度(Chroma)。色の濃さ・鮮やかさ 0(灰色)〜 0.4程度
    H 色相(Hue)。赤・黄・緑といった色味 0〜360度

    HSL と似ていますが、決定的に違うのは L が「知覚的な明るさ」である点です。

    HSL で「明度50%」に設定した黄色と青を並べると、数値は同じでも黄色は眩しく、青は暗く沈んで見えます。OKLCH ではこのズレが補正されているので、L=0.5 の色はどの色相でも同じくらいの明るさに見えます。

    パレットを作るうえでは、これは非常に大きな利点です。「500番」と名前を付けた色が、赤でも緑でも同じトーンになる。実際、私のパレットでも --c-success-500(緑)の白背景に対するコントラスト比が 1.57 → 3.22 に改善し、「緑だけ読めない」という問題が解消しました。


    問題は C が「絶対値」であること

    ここからが本題です。

    OKLCH の C(彩度)は 絶対値です。C=0.15 と書けば、どの色相でもどの明度でも「0.15 という濃さ」を要求します。

    しかし、先ほど述べた通り、その明度・色相で実際に出せる彩度の上限は、色相によってまるで違います。

    下のグラフは、sRGB で表現できる彩度の上限が、明度によってどう変わるかを色相別に描いたものです。

    色相ごとの彩度上限カーブ sRGBで表現できる彩度の上限が、色相と明度によってどう変わるかを示す折れ線グラフ。黄はピークが明度0.97付近と極端に明るい側にあり、暗くすると急激に彩度を失う 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.1 0.2 0.3明度 L(0=黒 → 1=白)出せる彩度の上限 C 黄 (H=95) 橙 (H=53) 赤 (H=29) 緑 (H=146) 青 (H=264)

    黄(H=95)のカーブを見てください。ピークが L=0.88 と極端に明るい側にあり、そこから暗くなると急激に落ち込みます。対して青(H=264)はピークが L=0.49 と中間にあり、暗い側でも高さを保っています。

    つまり、同じ C=0.15 を要求しても、結果はこうなります。

    色相 L=0.35 での上限 要求の達成率 結果
    青 0.205 100% 濃紺
    赤 0.144 96% 深い赤
    緑 0.107 71% 深緑
    橙 0.089 59% 焦げ茶
    黄 0.072 48% 泥色

    黄色だけが、要求した彩度の半分以下しか出せていません。「黄色だけ彩度が落ちる」の正体はこれです。

    上限を超えるとどうなるか

    上限を超えた指定をしても、エラーにはなりません。ブラウザが表示できる範囲に丸めます(gamut mapping/色域マッピング)。

    ただしこの丸め処理は、彩度を落とすだけでなく明度や色相もわずかにずらします。コード上は oklch(0.35 0.15 95) と書いてあるのに、実際に描画されるのは別の色、という状態になります。

    「OKLCH は色相が信用できない」という指摘をたまに見かけますが、その多くは 最初から色域外の彩度を指定していて、丸め処理の結果を見ているケースです。色域内に収まる範囲で使えば、OKLCH の挙動は十分に予測可能です。


    黄色に特別な処理をするしかない

    この問題は、計算式の工夫では回避できません。「暗くて鮮やかな黄色」は物理的に存在しないので、どんなに賢いアルゴリズムを書いても作れないからです。

    実は、Tailwind CSS も黄色を特別扱いしています。各色の色相が明度によってどう動いているかを調べると、はっきりした傾向が出ました。

    色 最も明るい段の色相 最も暗い段の色相 移動量
    amber 95.3 45.6 -49.6°
    yellow 102.2 53.8 -48.4°
    orange 73.7 36.3 -37.4°
    red 17.4 26.0 +8.7°
    blue 254.6 267.9 +13.3°
    emerald 166.1 172.6 +6.4°

    暖色系だけ、暗くするにつれて色相を40〜50度も赤側へ回している。 寒色は誤差程度しか動かしていません。

    これは手作業で色を調整した結果としてそうなったのだと思いますが、理屈の裏付けがあります。先ほどのグラフの通り、色相を赤側へずらすと、暗い領域での彩度の上限が上がるからです。

    色相 L=0.35 での上限 色
    H=95(黄) 0.072 濁った暗いオリーブ
    H=75 0.074
    H=55(橙) 0.087
    H=35(赤寄り) 0.123 深い赤茶

    黄色を暗くしたいときは、素直に色相をオレンジ〜赤側へ寄せる。そうすれば「暗いゴールデンイエロー」として自然に見える色が作れます。逆に明るくするときは黄側へ寄せると、鮮やかなレモンイエローになります。


    実際にやった対策

    自分のパレットは、基準色1つから23段の明度スケールを自動生成する仕組みです。デザイナーから指定された色をそのまま使いたいので、全段を手打ちするわけにはいきません。そこで、生成ロジックに2つの補正を入れました。

    対策1:明度に応じて色相を回す

    暗い段ほど色相を赤側へ、明るい段ほど黄側へ回します。CSS の相対カラー構文なら calc(h + N) で書けるので、基準色を差し替えたときも追従します。

    --c-warning-100: oklch(from var(--c-warning) 0.943 0.0888 calc(h + 42.5));
    --c-warning-350: oklch(from var(--c-warning) 0.734 0.1756 calc(h + 0.8));
    --c-warning-800: oklch(from var(--c-warning) 0.351 0.1108 calc(h - 19.5));

    回す量のカーブは、Tailwind の amber の実測値をそのまま形状として借用しました。ただし絶対値を使うのではなく、基準色の明度における値を0点にした差分だけを使います。こうすれば、指定された基準色そのものの色相は変わりません。

    振れ幅は明るい側と暗い側で分けています。暗い側を回しすぎると、エラー色(赤)と見分けがつかなくなるためです。

    対策2:彩度を「上限に対する割合」で指定する

    色相シフトを入れた直後、今度は淡い段が白っぽく薄くなるという新しい問題が起きました。原因を数値で追うと、こうなっていました。

    状態 要求 C その色相の上限 相対 結果
    シフト前(H=52.7) 0.0376 0.0333 113% 上限に張り付いて鮮やか
    シフト後(H=95.2) 0.0376 0.0929 41% 薄い

    色相を黄側へ回したことで色域が一気に広がったのに、要求する彩度は同じままだったわけです。橙のときは上限を超えていたので「出せるだけ出す」状態で鮮やかに見えていたのが、黄に移った途端に上限の41%まで落ちた、という構図でした。

    そこで、この色だけ彩度の指定方法を変えました。「基準色の彩度の何倍」ではなく、「その明度・色相で出せる上限の何%」 で指定します。

    // その明度・色相で sRGB に収まる最大の彩度を二分探索で求める
    @function max-chroma($l, $h, $steps: 13) {
    	$lo: 0;
    	$hi: 0.45;
    
    	@for $i from 1 through $steps {
    		$mid: math.div($lo + $hi, 2);
    
    		@if color.is-in-gamut(oklch($l $mid $h), srgb) {
    			$lo: $mid;
    		} @else {
    			$hi: $mid;
    		}
    	}
    
    	@return $lo;
    }

    Dart Sass 1.79 以降なら color.is-in-gamut() が使えるので、ビルド時に上限を求められます。あとは割合を掛けるだけです。

    この「上限に対する割合」という考え方は、OKLCH の考案者が Okhsl というカラースペースで採用しているものと同じです。Okhsl の saturation は「同じ明度・色相で出せる最も鮮やかな色に対する相対値」として定義されています。CSS の oklch() にはこの機能がないので、ビルド時に自前で計算する形になります。

    結果

    段 対策前 対策後
    -40 #FFF1E9(ペールオレンジ) #FEF5D3(クリーム)
    -100 #FFE7D8 #FCECAF(黄)
    -200 #FFC6A3 #F9CE63
    -500 #CF6600 #D55E2B
    -800 #5E2A00(泥色) #642215(赤茶)

    淡い側はきちんと黄色になり、暗い側も濁りのない赤茶色になりました。割合は 0.68〜0.87 に設定していて、上限に張り付かない値なので、色域マッピングによる色相・明度のズレも起きません。


    まとめ

    • 色域(gamut)は色相ごとに形が違う。 黄色は明るい側でしか鮮やかになれず、青は暗い側でも鮮やかさを保てる
    • OKLCH の C は絶対値なので、同じ数値を指定しても色相によって達成できる度合いが全く違う
    • 「黄色だけ彩度が落ちる」のは OKLCH の欠陥ではなく、ディスプレイの物理的な制約。計算式では回避できない
    • 対策は2つ。暗くするほど色相を赤側へ回すことと、彩度を上限に対する割合で指定すること
    • Tailwind CSS も暖色だけ色相を40〜50度動かしている。手作業でも同じ結論に行き着く

    OKLCH は「L を揃えればトーンが揃う」という点で HSL より遥かに扱いやすいモデルです。ただし C だけは色域を意識して使う必要があります。そこさえ押さえれば、色相が暴れることもなく、十分に予測可能な道具として使えます。