よくある状況です。ブランドカラーが #EA580C(Tailwind の orange-600)のようなオレンジで、ランディングページの本文を #FFF7ED のクリーム色の背景に載せたところ、アクセシビリティ監査で本文すべてが WCAG AA 不合格と指摘される。コントラスト比は 3.35 : 1、しきい値は 4.5 : 1。さて——フラットな黒に切り替えてブランドアイデンティティを捨てるか?

カラーコントラストチェッカーを開く →

このガイドが扱うのは「監査が落ちた」と「修正をリリースした」の間の数時間。WCAG 2.x のコントラスト式、実際に通すべき三段のしきい値、現場でハマりやすい失敗モード、そしてブランドの色相を保ったまま明度だけを最小限動かして AA を通す具体アルゴリズム——これらを順に解いていきます。

WCAG が実際に測っているもの

WCAG 2.x のコントラストは相対輝度(relative luminance)の比であって、感じる明るさそのものではありません。式は三段階:

  1. 各 sRGB チャンネル c を gamma 復号:
    • c / 255 ≤ 0.04045 なら c_lin = (c / 255) / 12.92(WCAG 2.0 / 2.1 の原文は 0.03928。両者の間に 8 ビット値はないので結果は同じ)
    • それ以外は c_lin = ((c / 255 + 0.055) / 1.055) ^ 2.4
  2. 線形化したチャンネルを単一の輝度に重み付け合成:
    • L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
  3. 明るい方を分子に置いて比を取る:
    • ratio = (L_max + 0.05) / (L_min + 0.05)

+ 0.05 は flare offset で、片方の輝度が 0 に近いとき比が発散するのを防ぎます。値は 1(コントラストなし)から 21(純黒×純白)の間。AA/AAA のすべてのしきい値——4.5、7、3——は、この一つの数字に対するカットオフでしかない。

輝度係数 0.2126 / 0.7152 / 0.0722 は ITU-R BT.709 sRGB 仕様由来で、物理的事実を符号化しています:人間の目は緑にもっとも敏感で、青にもっとも鈍い。同じ RGB 強度の純赤・純緑・純青は、輝度として大きく異なります。緑が知覚される光の大部分を担っている。

実際に通すべき三段のしきい値

WCAG はテキストと非テキストを三段に分け、各段に AA / AAA 目標を置きます:

要素AAAAA対象
本文(AA 1.4.3、AAA 1.4.6)4.5 : 17 : 124 px 通常未満、または 18.66 px 太字未満
大きな文字(AA 1.4.3、AAA 1.4.6)3 : 14.5 : 118.66 px 太字以上、または 24 px 通常以上(WCAG の原文は 18 pt と 14 pt 太字)
UI コンポーネントと図形(1.4.11)3 : 1—枠線・フォーカスリング・アイコン・グラフ線

現場でつまずきやすい三点:

  1. 一般的な法令の基準は AAA を求めていない。Section 508 が取り込んでいるのは WCAG 2.0 の AA、EN 301 549(欧州アクセシビリティ法 EAA が使う規格)が対応するのは WCAG 2.1 の AA です。日本の JIS X 8341-3:2016 も WCAG 2.0 と一致する規格で、公的機関の目標は AA 準拠です。W3C 自身もサイト全体に AAA を一律に求めることは勧めていません(Understanding Conformance)。契約や社内基準が特定ページに AAA を求める場合は、明記されています。
  2. 大きな文字は 3 : 1 と緩い。マーケのランディングページで巨大なセリフ見出しが、本文より遥かに低コントラストのアクセントカラーを使えるのはこのため。
  3. UI には AAA がない。WCAG 2.1 で 1.4.11 が 3 : 1 として追加され、そこで止まっています。存在しない「UI AAA ルール」を探して時間を浪費しないこと;社内基準でより厳しくしたいなら、自前で定義して根拠を明文化する。

現場でハマる失敗モード

下に並べたのはどれも珍しいケースではなく、気付かないまま本番に出しやすいものです。

「AA 通ったから出す」——でも監査ツールは別の物差しで測っている

WCAG 2.x だけがコントラストモデルではありません。APCA(Accessible Perceptual Contrast Algorithm)は知覚明度のコントラストを別スケール(Lc)で測り、最近の監査ツールは両方を表示するものもあります。APCA は WCAG 3 向けに提案されたものですが、WCAG 3 はまだワーキングドラフトで、正式なコントラスト式は定まっていません。WCAG 2.x で作って監査ツールが APCA を出してきたら、数値が合わないのはバグではなく、物差しが二つあるだけです。

法律(Section 508 / EN 301 549 / EAA / 日本の JIS X 8341-3:2016)が引いているのは依然 WCAG 2.x なので、当面は 2.x で出荷。APCA は研究シグナルとして社内で並行追跡。

半透明テキスト

コントラスト式は不透明色にしか効きません。rgba(20, 20, 20, 0.6) のテキストがテクスチャ背景に乗ると、実際にレンダリングされる色は下のレイヤーに依存します。前景を想定する実背景に合成してから、合成後の色でコントラスト式を回す。多くのチームがこの一段を飛ばし、結果として写真やグラデ背景でだけ顕在化するコントラストバグを出荷します。

中間調背景は「前景を暗くする」を破壊する

不透明な背景なら、純粋な黒か白のどちらかは必ず 4.5 : 1 に届きますが、中間調ではぎりぎりです。#767676 では黒 4.62 : 1、白 4.54 : 1、#777777 になると白は 4.48 : 1 に落ちます。こうした背景では色付きの前景に余地がなく、直すべきは背景で、前景ではありません。デザイナーは反射的に「文字色をもう少し濃く」と言いがちですが、中間調ではその手は効きません。

Placeholder テキストもカウントされる

ブラウザは既定で ::placeholder を入力文字より薄く描画し、その色はブラウザごとに異なります。プレースホルダーも文字なので 1.4.3 の対象です。::placeholder の色を明示し、その色と入力欄の背景でコントラストを測りましょう。

フォームラベルと disabled 状態

1.4.3 が除外しているのは「アクティブではないユーザインタフェース コンポーネントの一部である文字」です。disabled なコントロールの隣にある可視ラベルがコンポーネントの一部に当たるかは達成基準に書かれておらず、監査者によって判断が分かれます。ラベルを 4.5 : 1 に保っておけば議論になりません。

「色相を保って明度を動かす」の中身

#FFF7ED 上の #EA580C が AA に落ちて 3.35 : 1 のとき、最初に思いつく修正は「黒にする」。でもブランドカラーは色相アイデンティティのために選ばれたものです。黒にしたらブランドを捨てたことになる。正しい動作:色相と彩度を保ったまま、知覚明度だけをコントラストが通るところまで動かす。

これを綺麗にやるには OKLCH(Oklab を極座標化したもの)が適切です。理由は L 軸が知覚的に均一だから——L = 0.6 から L = 0.5 に動かすのと、L = 0.4 から L = 0.3 に動かすのが同じ感覚量に見える。sRGB の明度はこの性質を持たず、等距離 sRGB ステップは暗端と明端で見え方が大きく変わる。

アルゴリズム:

function fix(fg, bg, target = 4.5) {
  const fgLch = rgbToOklch(fg);          // hue h と chroma C を保持
  // 両方向を試す:L を下げる(暗くする) vs 上げる(明るくする)
  let best = null;
  for (const dir of [-1, 1]) {
    let lo = fgLch.L;
    let hi = dir < 0 ? 0 : 1;
    // 二分法:開始点からの |delta L| が最小で目標を満たす L を探す
    for (let i = 0; i < 22; i++) {
      const mid = (lo + hi) / 2;
      const trial = clampToSrgb(oklchToRgb({ ...fgLch, L: mid }));
      if (contrast(trial, bg) >= target) hi = mid;
      else lo = mid;
    }
    const final = clampToSrgb(oklchToRgb({ ...fgLch, L: hi }));
    const r = contrast(final, bg);
    if (r >= target * 0.99) {
      const delta = Math.abs(hi - fgLch.L);
      if (!best || delta < best.delta) best = { rgb: final, ratio: r, delta };
    }
  }
  return best ?? blackOrWhiteFallback(bg);
}

二点補足:

  • target * 0.99 の許容幅は OKLCH ↔ sRGB 行列の浮動小数点誤差を吸収するためのもの。結果を 8 ビットの 16 進値に丸めると比は上下どちらにも動くので、出荷前に最終的な 16 進値で測り直してください。WCAG のしきい値は丸めません(4.499 : 1 は不合格)。
  • 純黒/純白へのフォールバックは、色相が sRGB ガマット内で目標に届かないときの誠実な答え。ネオンイエローを黄色背景に置いて AA を通すことは、明度をどう動かしても無理。アルゴリズムはこれを認めるべきで、騙してはいけない。

#EA580C(ブランドオレンジ)を #FFF7ED(暖クリーム)上で試すと、ツールの提案は #D03F00、16 進値で測って 4.50 : 1(4.5025)です。オレンジとしては判別できますが、色相は完全には保たれません。明度を下げると青チャンネルが負になり、sRGB 色域に切り詰めた結果 OKLCH の色相が 0.718 から 0.654 rad に動きます。監査は通ります。

各スタックのコード例

Python(Pillow + 自前輝度)

def srgb_to_lin(c):
    c = c / 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

def luminance(rgb):
    r, g, b = (srgb_to_lin(c) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast(c1, c2):
    L1, L2 = sorted([luminance(c1), luminance(c2)], reverse=True)
    return (L1 + 0.05) / (L2 + 0.05)

# (234, 88, 12) on (255, 247, 237)
print(round(contrast((234, 88, 12), (255, 247, 237)), 2))  # 3.35

外部依存なし。Pillow は画像から色を読みたいときだけ。既知の前景/背景ペアなら、計算は十五行で収まる。

JavaScript(ブラウザ、ライブラリなし)

const lin = c => (c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const lum = ({ r, g, b }) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const ratio = (a, b) => {
  const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};

コアはこれだけ。OKLCH 変換を加えても四十行ほど、一ファイルに収まる。一つのツールだけで使うなら chroma.js や culori を引く必要なし——ライブラリは色操作を十種類使うときに引くもので、一種類のときに引くものではない。

Bash(CI ゲート)

# tokens.json のいずれかが背景に対して 4.5:1 を切ったら CI を落とす
# wcagContrast(fg, bg) は上の JavaScript 例の比率関数(引数は 16 進文字列)
node -e '
  const tokens = require("./tokens.json");
  const bg = tokens["color.background.default"];
  let failed = 0;
  for (const [k, fg] of Object.entries(tokens)) {
    if (!k.startsWith("color.text.")) continue;
    const r = wcagContrast(fg, bg);
    if (r < 4.5) {
      console.error(`FAIL ${k} ${fg} on ${bg} = ${r.toFixed(2)}`);
      failed++;
    }
  }
  process.exit(failed ? 1 : 0);
'

これが残るアクセシビリティテストの形です。手動監査は流れていく;design token を読み、PR ごと build を落とす CI ゲートこそが、人がレビューを開く前に問題を捕まえてくれる。

他のチェッカーとの違い

WebAIM のコントラストチェッカーは事実上の標準で、置き換えを狙ってはいません。覚えておく価値のある違い:

  • 色相を保つ修正:WebAIM は合否を示し、選んだ色を段階的に明るく / 暗くするボタンを備えています。Chrome DevTools のカラーピッカーはページ上の要素 1 つに対して合格する色を提案できます。本ツールは入力した任意の組み合わせについて OKLCH の明度軸を探索し、AA を通すための最小明度変動を報告します。
  • 三段階を一画面:WebAIM と同様に、本文・大きな文字・UI コンポーネントを並べ、それぞれの AA / AAA しきい値と照らし合わせて表示します。
  • 四言語対応:英語以外のデザインチームと働くとき、または社内向けの a11y ガイドラインを配るとき有用。
  • アップロードなし:すべて手元で完結。入力した hex はどのサーバーにも届かない。NDA 中のブランドカラーには大事な特性。

日本市場での補足

  • 公的セクター案件では JIS X 8341-3:2016 が事実上の参照点であり、WCAG 2.0 AA レベルへの準拠を求めます。WCAG 2.1 / 2.2 ベースで作っておけば、JIS への遡及は問題になりにくい。
  • 日本語フォントの「大きな文字」境界:CJK グリフは 18.66 px 太字程度ではラテン字と比べて画線が潰れやすい。デザインの実測上は 22 px 太字 / 28 px 通常を「大きな文字」の起点として扱うほうが安全。
  • placeholder は日本語入力フィールド(IME 起動前)に出る頻度が高く、placeholder のコントラストは本文と同じ要件で扱うべき。
  • Qiita / Zenn のテンプレートに付属する dark theme は WCAG AA を通っているが、AAA は通っていないものが多い。社内ブログを共有するときの参考。

関連ドキュメント

ZeroTool の関連ツール