ブランドカラーに #B45309 を選んだ。マーケも CEO も気に入った。#FFF7ED の背景に #B45309 を載せたランディングページをデプロイして仕事終わり——のはずが、二週間後にアクセシビリティ監査の指摘が届く。本文すべてが WCAG AA 不合格。コントラスト比は 3.86 : 1、しきい値は 4.5 : 1。さて——フラットな黒に切り替えてブランドアイデンティティを捨てるか?

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

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

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

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

  1. 各 sRGB チャンネル c を gamma 復号:
    • c / 255 ≤ 0.03928 のとき、c_lin = (c / 255) / 12.92
    • それ以外は 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対象
本文(1.4.3)4.5 : 17 : 118 px 通常未満、または 14 px 太字未満
大きな文字3 : 14.5 : 118.66 px 太字以上、または 24 px 通常以上
UI コンポーネントと図形(1.4.11)3 : 1枠線・フォーカスリング・アイコン・グラフ線

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

  1. AAA は「あれば嬉しい」だけではない。WCAG は AAA を発展目標と位置づけますが、業界によっては(米国 Section 508 連邦調達、EU 公共部門の一部)製品の特定領域で AAA を要求します。設計と交渉する前に、自分のプロダクトのどこにこの義務が乗っているかを確認しておきましょう。
  2. 大きな文字は 3 : 1 と緩い。マーケのランディングページで巨大なセリフ見出しが、本文より遥かに低コントラストのアクセントカラーを使えるのはこのため。
  3. UI には AAA がない。WCAG 2.1 で 1.4.11 が 3 : 1 として追加され、そこで止まっています。存在しない「UI AAA ルール」を探して時間を浪費しないこと;社内基準でより厳しくしたいなら、自前で定義して根拠を明文化する。

現場でハマる失敗モード

下に並べたのはどれも珍しいケースではありません。会社ごとに少なくとも一度は本番に届いている。

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

WCAG 2.x はコントラスト指標の唯一解ではありません。WCAG 3 草案で提案中の APCA(Advanced Perceptual Contrast Algorithm)は知覚的明度コントラストを測り、目盛りは Lc。最近の監査ツールはこの両方を表示することが増えています。APCA は WCAG 3 のためのもので、現状は draft。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) のテキストがテクスチャ背景に乗ると、実際にレンダリングされる色は下のレイヤーに依存します。前景を想定する実背景に合成してから、合成後の色でコントラスト式を回す。多くのチームがこの一段を飛ばし、結果として写真やグラデ背景でだけ顕在化するコントラストバグを出荷します。

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

背景輝度が 0.18 あたり(#888 付近)にあるとき、純黒も純白も 4.5 : 1 に届きません。黒で約 4.0 : 1、白で約 4.4 : 1 が頭打ち。この場合、変えるのは背景であって前景ではない。デザイナーは反射的に「もう少し文字を濃く」と言ってきますが、中間調ではこの動作は無効。

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

ほとんどのブラウザは ::placeholder をデフォルトで 50% 不透明度でレンダリングします。つまり #1f2937 の placeholder は #f9fafb 背景上で実効的に #88888d 相当となり、AA を通らない。WCAG は placeholder をテキストとして扱います。placeholder の明度を上げるか、入力前は無障碍適合外と認めるかの二択。

フォームラベルと disabled 状態

WCAG 2.x は disabled コントロールそのものをコントラスト要件から免除しますが、それだけ。disabled コントロールの隣にあるラベルは免除されません。監査ツールはこの区別をしばしば見落とすので、人間レビューが拾うべき。

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

#FFF7ED 上の #B45309 が AA に落ちて 3.86 : 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 行列の浮動小数誤差を吸収します。これがないと、数学的には 4.5 : 1 の結果が 4.4998 : 1 と読まれて却下される。
  • 純黒/純白へのフォールバックは、色相が sRGB ガマット内で目標に届かないときの誠実な答え。ネオンイエローを黄色背景に置いて AA を通すことは、明度をどう動かしても無理。アルゴリズムはこれを認めるべきで、騙してはいけない。

#B45309(ブランドオレンジ)を #FFF7ED(暖クリーム)上で走らせると、着地は約 #925108——同じ色相、わずかに深く、比は 3.86 : 1 から 4.50 : 1 へ。ブランド識別が残り、監査も通る。

各スタックのコード例

Python(Pillow + 自前輝度)

def srgb_to_lin(c):
    c = c / 255.0
    return c / 12.92 if c <= 0.03928 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)

# (180, 83, 9) on (255, 247, 237)
print(round(contrast((180, 83, 9), (255, 247, 237)), 2))  # 3.86

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

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

const lin = c => (c /= 255) <= 0.03928 ? 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.jsculori を引く必要なし——ライブラリは色操作を十種類使うときに引くもので、一種類のときに引くものではない。

Bash(CI ゲート)

# tokens.json のいずれかが背景に対して 4.5:1 を切ったら CI を落とす
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 は「落ちた」とだけ告げる。ブラウザ DevTools のカラーピッカーは「スライダーを動かして直せ」と言う。どちらも OKLCH の明度軸を自動で歩かない。本ツールは歩き、AA を通すための最小明度変動を報告する。
  • 三段階を一画面:本文・大きな文字・UI コンポーネントは別々のしきい値を持つ。多くのチェッカーは headline 数字だけを返すが、本ツールは三段階に分けて返すので、頭の中でしきい値を再計算しなくて済む。
  • 四言語対応:英語以外のデザインチームと働くとき、または社内向けの 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 の関連ツール