CSS の contrast-color() 関数が 2026 年 4 月、ようやく全モダンブラウザで使えるようになりました。背景色を渡すと「白か黒」どちらか読みやすい方を返してくれるというシンプルな関数です。
しかし実際に使ってみると「これ、背景色を明示的に渡さないといけないのか……」と気付きます。MDN のサンプルでも、カラーピッカーの値を JavaScript で CSS カスタムプロパティに代入してから contrast-color(var(--button-color)) と書く、という使い方になっています。
:root {
--button-color: lightblue;
}
button {
background-color: var(--button-color);
color: contrast-color(var(--button-color)); /* テキストの色を自動的に対照的に設定 */
}
「自分の背景色を参照する」だけの用途なら、color: contrast-color() のように引数なしで書けたら便利なのですが、現状の仕様ではそれができません。
そこで今回、JavaScript で --contrast-color という CSS 変数を自動セットする仕組みを作ってみました。
ついでに hover 時の背景色・文字色も自動計算できるようにしています。
完成イメージ
CSS 側ではこう書けます:
.button {
background: #006571;
color: var(--contrast-color); /* 背景色に対して見やすい白/黒が自動で入る */
}
.button:hover {
background: var(--hover-bgcolor); /* hover時の背景色も自動 */
color: var(--contrast-color);
}
.button.is-ghosted:hover {
background: var(--hover-color); /* 透明背景ボタン用の薄いtint */
}
JavaScript は対象要素を querySelectorAll で拾って、各要素の背景色から --contrast-color / --hover-bgcolor / --hover-color を計算して element.style.setProperty() で書き込むだけです。
将来 contrast-color() がもっと普及したら、var(--contrast-color) を contrast-color(currentBackground) のような書き方に置換すれば移行できます(が、現時点ではまだ CSS だけでは書けないので、この JS 版の方が便利です)。
コード全体
(function () {
if (window._contrastColorInitialized) return;
window._contrastColorInitialized = true;
// RGB → HSL 変換
const rgbToHsl = (r, g, b) => {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
};
// 輝度計算(WCAG 2.0 相対輝度)
const getColorLuminance = (r, g, b) => {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};
// RGB文字列のパース
const parseRgb = (colorStr) => {
if (!colorStr || colorStr === 'transparent') return null;
const match = colorStr.match(/[\d.]+/g);
if (!match) return null;
const [r, g, b, a] = match.map(Number);
if (a === 0) return null;
return [r, g, b];
};
// hover色計算
const calcHoverColor = (r, g, b, isDark) => {
const [h, s, l] = rgbToHsl(r, g, b);
let hoverS, hoverL;
if (isDark) {
hoverS = Math.round(s * 0.875);
hoverL = Math.min(l + 8, 100);
} else {
hoverS = Math.min(Math.round(s * 1.15), 100);
hoverL = Math.max(l - 8, 0);
}
return `hsl(${h}, ${hoverS}%, ${hoverL}%)`;
};
function applyContrastColor() {
const targets = document.querySelectorAll(
':is(.button, .buckle, .badge, .bauble, .bubble, .icon, .title, .caption, .box, .label, .alert)'
);
targets.forEach(el => {
const style = window.getComputedStyle(el);
const bgRgb = parseRgb(style.backgroundColor);
const isGhosted = el.classList.contains('is-ghosted');
if (bgRgb) {
const [r, g, b] = bgRgb;
const luminance = getColorLuminance(r, g, b);
const isDark = luminance <= 0.55;
el.style.setProperty(
'--contrast-color',
isDark ? '#FFF' : 'var(--c-text, hsl(223, 6%, 13%))'
);
el.style.setProperty('--hover-bgcolor', calcHoverColor(r, g, b, isDark));
} else if (isGhosted) {
const borderRgb = parseRgb(style.borderColor);
if (borderRgb) {
const [r, g, b] = borderRgb;
const luminance = getColorLuminance(r, g, b);
const isDark = luminance <= 0.55;
el.style.setProperty(
'--hover-color',
isDark
? `rgba(${r}, ${g}, ${b}, 0.08)`
: calcHoverColor(r, g, b, false)
);
}
}
});
}
function initContrastColor() {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
applyContrastColor();
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('load', initContrastColor, { once: true });
}, { once: true });
} else if (document.readyState === 'interactive') {
window.addEventListener('load', initContrastColor, { once: true });
} else {
initContrastColor();
}
})();
使い方
基本:そのまま読み込むだけ
対象セレクタの条件に合う要素であれば、ページ読み込み時に自動で CSS 変数がセットされます。
<link rel="stylesheet" href="style.css">
<script src="_style-contrastColor.js" defer></script>
対象セレクタを変更する
デフォルトでは .button, .badge, .alert 等を対象にしています。自分のプロジェクトに合わせて querySelectorAll の引数を書き換えてください。
// 変更前
const targets = document.querySelectorAll(
':is(.button, .buckle, .badge, ...)'
);
// 変更後(例:.button, .badge にだけ適用)
const targets = document.querySelectorAll('.button, .badge');
白黒判定の閾値を変える
デフォルトは luminance <= 0.55 で白背景と黒背景を切り替えています。値を小さくすると「白文字になる背景」が増え、大きくすると「黒文字になる背景」が増えます。
const isDark = luminance <= 0.55; // デフォルト
const isDark = luminance <= 0.45; // より暗い背景だけ白文字に
const isDark = luminance <= 0.65; // より多くの背景で白文字に
WCAG 2.0 の輝度式は中間明度(くすんだ青や緑など)で判定が揺れることがあるので、プロジェクトのパレットに合わせて微調整すると良いです。
hover 色の濃さを調整する
calcHoverColor 関数で HSL の明度を上下させています。+8 / -8 の部分を増減すると hover 時の変化量が変わります。
hoverL = Math.min(l + 8, 100); // デフォルト(控えめな変化)
hoverL = Math.min(l + 15, 100); // はっきりした変化
コードのかんたん解説
1. 背景色の取得は getComputedStyle
インラインスタイルだけでなく、CSS ファイルで指定された背景色も含めて実際に適用されている値を取得したいので window.getComputedStyle(el).backgroundColor を使います。戻り値は rgb(2, 127, 65) のような文字列になるので、正規表現で数値を取り出しています。
2. 輝度は WCAG 2.0 の相対輝度式
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
RGB の各チャンネルを sRGB ガンマで線形化してから係数をかけて合計するという標準的な計算です。緑が一番明るく感じられる(人間の目が緑に敏感)ので 0.7152 と重みが大きくなっています。
3. hover 色は HSL の明度を上下
RGB のまま「少し暗く」しようとすると色相がずれるので、一度 HSL に変換して L(明度)だけを 8% 上下させています。暗い色は少し明るく、明るい色は少し暗く、という自然な hover 変化になります。
4. requestAnimationFrame を 2段重ね
requestAnimationFrame(() => {
requestAnimationFrame(() => {
applyContrastColor();
});
});
CSS カスタムプロパティ(var(--c-primary) 等)がスタイルシートから解決されるのを 1 フレーム待ち、さらに getComputedStyle が最新値を返すのを待つために 2 段重ねにしています。1 段だけだと getComputedStyle が古い値を返すことがあるので、安全策として 2 段にしました。
5. .is-ghosted(透明背景)の特別処理
background: transparent のボタンは背景色から輝度を計算できないので、代わりに borderColor を見ています。hover 時はボーダー色を薄く塗る挙動にしたいので、暗いボーダーなら rgba(r, g, b, 0.08)、明るいボーダーなら HSL 調整版を返しています。
APCA について(余談)
今回は WCAG 2.0 の輝度式を使いましたが、最近は APCA(Accessible Perceptual Contrast Algorithm) という新しいコントラスト計算アルゴリズムが注目されています。
WCAG 2.0 の輝度式には「暗い背景での誤判定が多い」「中間明度で黒文字を推奨するが実際は白の方が読みやすい」などの批判があり、APCA はそれらを改善したアルゴリズムです。WCAG 3 の候補アルゴリズムとして仕様策定が進められています。
WCAG 2.0 との違い
- WCAG 2.0:輝度比(単純な比率)で判定、閾値 4.5:1 など
- APCA:lightness contrast(Lc)という独自の数値を計算、閾値 ±45〜75 など
- APCA はテキストと背景の極性(どちらが暗いか)を考慮する
切替オプションを追加するなら
もしアルゴリズムを選べるようにするなら、以下のような設計が考えられます:
const options = {
algorithm: 'wcag', // 'wcag' | 'apca'
threshold: {
wcag: 0.55, // WCAG 用の輝度閾値
apca: 45 // APCA 用の Lc 閾値
}
};
function isDarkBackground(r, g, b, opts) {
if (opts.algorithm === 'apca') {
return calcAPCA(r, g, b) >= opts.threshold.apca;
}
return getColorLuminance(r, g, b) <= opts.threshold.wcag;
}
ただ、APCA は仕様が何度か更新されている(G-4g, G-4h 等)ことと、リファレンス実装(apca-w3)が AGPL 3.0 ライセンスであることに注意が必要です。実装する場合はライセンスに感染しないよう数式を参考に自力実装するか、MIT 互換の派生実装を探すのが無難です。
処理速度は WCAG 2.0 の約 2〜3 倍の演算量ですが、1 要素あたり数マイクロ秒レベルなので体感差は出ません(実際のボトルネックは getComputedStyle / setProperty 等の DOM 操作側)。
ひとまず「白背景なら黒文字、黒背景なら白文字」という単純な用途では WCAG 2.0 でも十分判定できるので、今回はそのまま採用しています。プロジェクトのパレットで誤判定が出るようなら APCA 化を検討する、というぐらいで良さそうです。
まとめ
- CSS
contrast-color()は便利だが「背景色を明示的に渡す必要がある」という弱点がある - JavaScript で
--contrast-color変数を自動セットすれば、CSS 側はcolor: var(--contrast-color)と書くだけで済む - ついでに
--hover-bgcolor/--hover-colorも自動計算すると、hover の記述もトークン化できる - 将来 CSS だけで完結できるようになったら、
var(--contrast-color)→contrast-color()への移行も楽
デザインシステム側でボタン色のバリエーション(.is-primary, .is-accent など)を CSS 変数で定義していれば、この JS はどのプロジェクトでもそのまま使い回せるはずです。閾値や対象セレクタだけプロジェクトごとに調整してください。
