CSS で色を書く人向けの話です。contrast-color() は背景色を渡すと読みやすい文字色を返してくれる関数ですが、返るのは白と黒の2色だけで、しかも「WCAG の数値は必ず通るのに読めない色」が残ります。この記事は Chrome 151(macOS 15)で実際に挙動を測り、仕様(CSS Color Module Level 5)と突き合わせた結果です。対応は Chrome 147・Firefox 146・Safari 26.0 で、2026年4月に Baseline Newly available になりました。Firefox と Safari は測っていないので、この記事の実測値はすべて Chrome 151 のものです。
contrast-color() が返すのは白か黒だけ
文法は引数1つだけです。
color: contrast-color(#2277d3);
color: contrast-color(var(--bg));仕様の記述はこうです。入力色をベタ塗りの背景として使ったときに、文字色として最大のコントラストを生む方に解決する。白と黒が同じコントラストなら白になる。
過去のドラフトにあった書き方は通りません。Chrome 151 で CSS.supports() を通すと、contrast-color(red max)、contrast-color(red AA)、contrast-color(red to-nearest white black)、contrast-color(red, blue) はすべて false でした。引数を2つ書いたり、目標コントラストをキーワードで指定したりはできません。
使える場所は広く、色を取るプロパティはほぼ全部通りました。color、background-color、border-color、outline-color、box-shadow、text-shadow、column-rule-color、fill、stroke、caret-color、accent-color、グラデーションの中、color-mix() の引数、light-dark() の中。入力側も自由で、var()、color-mix()、相対色構文の oklch(from ... )、color(display-p3 ...)、contrast-color() の入れ子が通りました。
いちばん大事な前提は、この関数は実際に描画されている背景を見ていないことです。見ているのは渡した色だけです。背景画像の上、グラデーションの途中、半透明パネルの重なりでは、正しい答えは出せません。
Chrome 151 で実測: 白から黒に切り替わる境界
グレースケールを rgb(0,0,0) から rgb(255,255,255) まで1刻みで256回読ませ、getComputedStyle() の値を記録しました。
結果は、#757575 までが白、#767676 から黒でした。切り替わりは rgb(118,118,118) です。
これは WCAG 2.1 の相対輝度で計算したクロスオーバーとぴったり一致します。白と黒のコントラスト比が入れ替わる輝度は L=0.179129 で、そこでの比は白黒どちらも 4.5826:1 です。実測した境界の両側を計算すると、#757575 は L=0.177888(vs白 4.608、vs黒 4.558)、#767676 は L=0.181164(vs白 4.542、vs黒 4.623)でした。
グレーだけでは足りないので、sRGB の立方体を32刻みで512色走査し、「WCAG 2.1 の比が大きい方」という予測と実測を突き合わせました。512色すべてで一致しました。
つまり Chrome 151 の挙動は WCAG 2.1 の比で説明できます。ここが面白いところで、仕様はアルゴリズムを UA 定義とした上で、「WCAG 2.1 の 1.4.3 のコントラスト比をそのまま使うことは推奨しない、既知の問題がいくつもあるため」と実装者に助言しています。それでも Chrome 151 の結果はその式と一致しました。Firefox 146 と Safari 26.0 は測っていないので、同じ背景色で文字色が逆になる可能性は残ります。
WCAG の数値は必ず通るのに、読めない色が残る
白と黒の良い方を選ぶ限り、WCAG 2.1 の式では最悪でも 4.5826:1 になります。理屈のとおりか確認するため、16刻みで4,913色を走査して返った色の実比を計算したところ、最小値は 4.584:1(rgb(208,64,96))でした。数値上は常に AA の 4.5:1 を超えます。
ところが MDN は、#2277d3(ロイヤルブルー)の背景に contrast-color() を使うと黒文字になり、小さい文字では読めないと警告しています。実測すると確かに黒でした。この色の比は vs黒 4.647:1、vs白 4.519:1 です。黒のほうが数値上は高く、AA も通ります。
数値が通るのに読めないのは、WCAG 2.1 の比が人間の知覚と合っていないからです。仕様が UA に「そのまま使うな」と言っているのは、まさにこの既知の問題を指しています。そして仕様が保証すると書いているのは AA の large text の水準までです。小さい文字の可読性は保証の範囲外です。
ここから引ける線は1つです。中間調の背景に小さい文字を置く設計では、この関数に最終判断を任せないこと。中間調を避けられる設計なら、任せてよいことです。
@supports でのフォールバック
Baseline Newly available は「全エンジンが対応したばかり」の段階です。Widely available になるのは全エンジン対応から30か月後なので、いま本番に置くならフォールバックが要ります。
.badge {
--bg: #1e3a8a;
background: var(--bg);
color: #fff; /* 未対応ブラウザ向け */
}
@supports (color: contrast-color(red)) {
.badge {
color: contrast-color(var(--bg));
}
}罠が1つあります。未対応かもしれない新機能を1つの宣言に混ぜると、宣言そのものが落ちます。相対色構文と組み合わせるなら、両方を同時に検査します。
@supports (color: contrast-color(red)) and (color: oklch(from red l c h)) {
/* ここに両方を使う宣言を置く */
}応用1: 色が実行時に決まる場所に効く
本領は「CSS を書く時点で背景色が分からない」場面です。CMS の設定色、ユーザーが選んだタグ色、取引先から渡されたブランドカラー。こういう色は CSS ファイルに書けません。
カスタムプロパティ1つで、背景と文字の両方が決まります。
.chip {
background: var(--tag);
color: contrast-color(var(--tag));
}<span class="chip" style="--tag: #22c55e">出荷済み</span>
<span class="chip" style="--tag: #1e3a8a">下書き</span>実測では #22c55e が黒(vs黒 9.216:1)、#1e3a8a が白(vs白 10.358:1)になりました。どちらも明暗がはっきりした色なので、この関数がいちばん得意な入力です。
効果は行数の削減より、責任の移動にあります。これまでは相対輝度を JS で計算してクラスを付け替えるか、色ごとに文字色をセットで持つ必要がありました。それが「色を1個 custom property で渡す」だけになります。色の出どころ(データベース、フォーム、API)が CSS の関心事から外れます。
light-dark() の中でも動きました。color-scheme: light で黒、dark で白になります。
.panel {
color: light-dark(contrast-color(var(--bg-light)), contrast-color(var(--bg-dark)));
}スタイルコンテナクエリの中でも解決されました。@container style(--flag: on) の中に置いた contrast-color(black) は白になりました。条件付きのテーマ切り替えと併用できます。
応用2: 中間調を作らない設計に寄せる
この関数の弱点は中間調です。返せるのが白黒だけなので、入力を中間調にしないことが唯一の制御手段になります。渡す前に色を明るいか暗いかに寄せます。
相対色構文で明度を固定する方法です。
.rel {
--deep: oklch(from var(--brand) 0.45 c h);
background: var(--deep);
color: contrast-color(var(--deep));
}--brand: #2277d3 で実測すると、背景は oklch(0.45 0.161716 254.155) になり、文字は白になりました。#2277d3 をそのまま渡すと黒(4.647:1、MDN が読めないと言っている組み合わせ)だったので、明度を 0.45 に固定するだけで安全側に振れます。色相と彩度はブランドのまま残ります。
color-mix() で暗く寄せる方法も同じ効果です。
.tone {
--tone: color-mix(in oklab, var(--brand), black 35%);
background: var(--tone);
color: contrast-color(var(--tone));
}#2277d3 に黒を35%混ぜた実測でも、文字は白になりました。
どちらも考え方は同じです。明度は自分で決め、白黒の選択だけを関数に任せる。 そうすると「境界のすぐ近くで、たまたま数値だけ通った色」が出てきません。
半透明とアニメーションの落とし穴
アルファは無視されます。 実測はこうです。
contrast-color(rgb(0 0 0 / 0.05))は白contrast-color(rgb(255 255 255 / 0.05))は黒contrast-color(transparent)は白
不透明度5%の黒は、画面上ではほぼ下地の色に見えます。それでも関数は「黒の背景」として扱い、白を返します。ガラス風のパネルや半透明のオーバーレイでは、下地と合成した色を自分で作ってから渡す必要があります。
.glass {
--mixed: color-mix(in oklab, var(--page-bg), var(--overlay) 8%);
background: var(--overlay);
color: contrast-color(var(--mixed));
}入力色をアニメーションすると、文字色は途中で飛びます。 @property で <color> として登録したカスタムプロパティを白から黒へアニメーションし、文字色を contrast-color(var(--bg)) にして中間値を読みました。--bg が rgb(128,128,128) の時点では文字は黒、rgb(117,117,117) になった時点で白に切り替わりました。境界をまたぐ瞬間にスナップします。滑らかに見せたい遷移では、背景の明度が境界を通らないように振り幅を決めます。
一方、contrast-color() 同士の補間は普通の色として滑らかに動きます。contrast-color(white) から contrast-color(black) へのアニメーションは rgb(64,64,64)、rgb(128,128,128)、rgb(191,191,191) を通りました。白黒に解決してから補間される、ということです。
currentColor も渡せます。親が black なら子は白、親が white なら子は黒になりました。@property で syntax: "<color>" に登録したカスタムプロパティに入れると計算値は rgb(...) に解決され、未登録のカスタムプロパティでは文字列 contrast-color(red) のまま残ります。トークンを未登録のまま持ち回すと解決が遅れる、という違いです。
ここは未解決・未確認
- Firefox 146 と Safari 26.0 の境界を測っていません。アルゴリズムは UA 定義なので、中間調に近い色では文字色がブラウザ間で入れ替わる可能性があります。境界付近の色を使うなら3ブラウザで見る必要があります。
- 半透明を渡したときにアルファを無視するのが仕様通りなのか、Chrome の実装判断なのかを、仕様文からは特定できていません。
- forced-colors モードと印刷時の挙動は測っていません。
- 仕様は将来、コントラストアルゴリズムの選択・用途の指定・返す色の指定を導入する見込みと書いています。いま白黒しか返らないのは Level 5 の時点の話で、ここは動きます。
一次情報と関連書籍
この記事の一次情報は2つです。MDN の contrast-color() のページと、CSS Color Module Level 5 の第8節です。
いっぽうでこの記事は仕様と実測に寄せて書いたので、CSS の色指定や配色を順番に学びたい人には向いていません。入門から体系的に追いたい場合は、次のような書籍があります。