文字色と背景色の WCAG コントラスト比を計算して AA / AAA 合否を出すツールを作った。一見「RGB の明るさの比でしょ?」と思うが、コントラスト比は生の RGB でもチャンネル平均でもなく「相対輝度 (relative luminance)」から計算する。そして相対輝度を出すには各チャンネルの sRGB ガンマを戻す (線形化) 必要がある。ここを飛ばした自作チェッカーは中間グレーで間違った比を出す。実装の hinge はこの計算式と、大きいテキスト/通常テキストで閾値が違う点。完全ブラウザ完結。
🌐 デモ: https://sen.ltd/portfolio/color-contrast-checker/
📦 GitHub: https://github.com/sen-ltd/color-contrast-checker
なぜ単純な RGB 比ではダメか
「白 (255) と灰 (128) の比は 255/128 ≈ 2」——これは間違い。理由は 2 つ:
- ディスプレイは sRGB ガンマがかかっている。ピクセル値 128 は物理的な明るさの 50% ではなく、約 21% しかない。人間が感じる明るさを得るにはガンマを戻す必要がある。
- 人間の目はチャンネルごとに感度が違う。緑に最も敏感で、青に最も鈍い。だから単純平均ではなく重み付き和。
WCAG はこれを「相対輝度」として厳密に定義している。
hinge: 相対輝度の計算
手順は 4 ステップ:
// 1 + 2. 0..255 を 0..1 にして sRGB ガンマを戻す (線形化)
export function linearizeChannel(c255) {
const c = c255 / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
// 3. 相対輝度 — 緑の重みが最大 (人間の目が緑に最も敏感)
export function relativeLuminance({ r, g, b }) {
return 0.2126 * linearizeChannel(r)
+ 0.7152 * linearizeChannel(g)
+ 0.0722 * linearizeChannel(b);
}
// 4. コントラスト比
export function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1), l2 = relativeLuminance(c2);
const lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
ポイントを順に:
線形化の分岐 c <= 0.03928 ? c/12.92 : ((c+0.055)/1.055)^2.4 は sRGB の伝達関数の逆変換。暗い領域は線形、それ以上はべき乗。この曲線を戻さないと、中間グレーの輝度が大幅にずれる。
重み 0.2126 / 0.7152 / 0.0722 は人間の視感度 (luminosity function) を反映。緑が圧倒的に大きい。テストで確認:
test("green is brightest channel", () => {
const g = relativeLuminance({ r: 0, g: 255, b: 0 }); // ≈ 0.7152
const r = relativeLuminance({ r: 255, g: 0, b: 0 }); // ≈ 0.2126
const b = relativeLuminance({ r: 0, g: 0, b: 255 }); // ≈ 0.0722
assert.ok(g > r && r > b);
});
末尾の +0.05 は画面の環境光反射 (ambient flare) をモデル化したもの。これがあるおかげで、純黒 (L=0) と純白 (L=1) の比が (1+0.05)/(0+0.05) = 21 ちょうどになる。コントラスト比の上限 21:1 の正体。
test("black on white = 21", () => {
approx(contrastRatio({r:0,g:0,b:0}, {r:255,g:255,b:255}), 21, 1e-2);
});
中間グレーで差が出る
線形化を飛ばした「なんちゃって実装」と正しい実装の差が一番出るのが中間グレー。#777777 を白背景で:
- 正しい計算: 4.48:1 → 通常テキスト AA (4.5) をわずかに下回る
- ガンマを戻さない素朴な計算: 比がずれて、AA を通過してしまうことがある
#777 on white が「AA ギリギリ不合格」なのは有名な事例なのでテストに入れた:
test("known pair: #777 on white ≈ 4.48", () => {
const r = contrastRatio(parseColor("#777"), parseColor("#fff"));
approx(r, 4.48, 0.05);
});
この 0.02 の差で AA 不合格になるかどうかが決まる。だから計算の正確さが効く。
閾値はテキストサイズで違う
WCAG の合否ラインは 1 つではない:
| 文脈 | AA | AAA |
|---|---|---|
| 通常テキスト | 4.5 | 7 |
| 大きいテキスト (18pt 以上 or 14pt 太字) | 3 | 4.5 |
| UI 部品・図形 | 3 | — |
export function wcagLevels(ratio) {
return {
normalAA: ratio >= 4.5,
normalAAA: ratio >= 7,
largeAA: ratio >= 3,
largeAAA: ratio >= 4.5,
uiAA: ratio >= 3,
};
}
test("4.5 is the normal-AA / large-AAA boundary", () => {
const l = wcagLevels(4.5);
assert.ok(l.normalAA && l.largeAAA && !l.normalAAA);
});
4.5 という同じ比が「通常 AA」と「大 AAA」の両方の境界になっているのが面白い。デモでは 5 種類の判定を PASS/FAIL バッジで一覧表示する。
色のパース
#rgb / #rrggbb / rgb(r,g,b) を受ける:
export function parseColor(input) {
let s = input.trim().toLowerCase();
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
if (m) { /* rgb() 形式 */ }
if (s.startsWith("#")) s = s.slice(1);
if (/^[0-9a-f]{3}$/.test(s)) { /* #rgb を #rrggbb に展開 */ }
if (/^[0-9a-f]{6}$/.test(s)) { /* #rrggbb */ }
return null; // 不正
}
#f80 → {r:255, g:136, b:0} のショートハンド展開も対応。不正な色は null を返して UI でエラー表示。
設計
contrast.js — 色パース、sRGB 線形化、相対輝度、比、WCAG 判定 (DOM-free, 32 tests)
app.js — ライブプレビュー + 合否テーブル
contrast.js は DOM 非依存なので 32 個のテストが Node で走る。UI 側はネイティブの color picker とテキスト入力を相互同期し、背景/文字色をプレビューに即反映する。
試してみる
- デモ: https://sen.ltd/portfolio/color-contrast-checker/
- GitHub: https://github.com/sen-ltd/color-contrast-checker
#777 を文字色、白を背景にすると 4.48 で「通常 AA は不合格、大きいテキストなら合格」が見える。少しずつ暗くして 4.5 を超える瞬間を探すと、アクセシブルな配色の感覚がつかめる。
まとめ
- コントラスト比は 相対輝度から計算する。生 RGB やチャンネル平均ではない。
- 相対輝度には sRGB ガンマの逆変換 (線形化) が必須。飛ばすと中間グレーで誤判定。
- 重みは 0.2126 / 0.7152 / 0.0722 (緑最大)。人間の視感度を反映。
- 比の式の
+0.05が環境光をモデル化し、上限を 21:1 にする。 - 閾値はテキストサイズで違う (通常 AA 4.5 / 大 AA 3)。同じ 4.5 が「通常 AA」と「大 AAA」の境界。
-
#777on white = 4.48 は「AA ギリギリ不合格」の有名事例。テストに入れて計算精度を守る。
これは SEN 合同会社の OSS ポートフォリオ #271 です。https://sen.ltd/portfolio/
