1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

文字色と背景色のコントラスト比をWCAG基準で判定するアルゴリズムをTypeScriptで実装する

1
Posted at

ぱんだツールズ にカラーコントラスト比チェッカーを追加した。文字色と背景色のHEXコードを入力すると、WCAG 2.1のAA/AAA基準の合否を判定し、基準を満たさない場合は合格する色を自動で提案する。

2024年4月施行の改正障害者差別解消法で事業者の合理的配慮提供が義務化されて以降、Webのアクセシビリティ対応の中でも「文字色コントラスト」は代表的なチェック項目の一つになっている。この記事では判定ロジックの実装を解説する。

コントラスト比は「相対輝度の比率」

WCAG 2.1が定めるコントラスト比の計算は、見た目の明るさではなく「相対輝度(relative luminance)」という指標をベースにしている。

まずRGB各チャンネルの8bit値(0〜255)を、sRGBのガンマ補正を解いて線形の値に変換する。

function toLinearChannel(channel8bit: number): number {
  const c = channel8bit / 255
  return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}

0.03928 という閾値より暗い領域は線形近似(/ 12.92)、それより明るい領域はガンマカーブ(Math.pow)と、区分的な式になっているのがポイントだ。ディスプレイの実際の輝度特性がこの形をしているため、WCAG仕様でもこの通りに定義されている。

線形化した各チャンネルに、人間の目が緑を最も明るく感じ、青を最も暗く感じるという知覚特性に基づいた重みを掛けて輝度を求める。

function relativeLuminance({ r, g, b }: Rgb): number {
  return 0.2126 * toLinearChannel(r) + 0.7152 * toLinearChannel(g) + 0.0722 * toLinearChannel(b)
}

0.2126 / 0.7152 / 0.0722 という係数は、CIE 1931の等色関数から導かれた固定値でWCAG仕様に明記されている。

最後に、明るい方の輝度と暗い方の輝度から比率を出す。

function contrastRatioFromLuminance(luminanceA: number, luminanceB: number): number {
  const lighter = Math.max(luminanceA, luminanceB)
  const darker = Math.min(luminanceA, luminanceB)
  return (lighter + 0.05) / (darker + 0.05)
}

分子・分母の両方に + 0.05 が入っているのは、輝度0(真っ黒)同士でもゼロ除算にならないようにする補正項で、これもWCAG仕様通りの式だ。白(#FFFFFF)と黒(#000000)を渡すと、この式は21:1になる。WCAGのコントラスト比が取りうる最大値がちょうど21というのは、この式の性質から来ている。

5つの基準を一度に判定する

WCAG 2.1にはテキストサイズや要素の種類ごとに複数の達成基準がある。今回のツールでは、算出した比率を5つの閾値と同時に比較している。

const AA_NORMAL_THRESHOLD = 4.5
const AA_LARGE_THRESHOLD = 3
const AAA_NORMAL_THRESHOLD = 7
const AAA_LARGE_THRESHOLD = 4.5
const UI_COMPONENT_THRESHOLD = 3

return {
  ratio,
  aaNormalText: ratio >= AA_NORMAL_THRESHOLD,
  aaLargeText: ratio >= AA_LARGE_THRESHOLD,
  aaaNormalText: ratio >= AAA_NORMAL_THRESHOLD,
  aaaLargeText: ratio >= AAA_LARGE_THRESHOLD,
  aaUiComponent: ratio >= UI_COMPONENT_THRESHOLD,
}

見落としがちなのが aaUiComponent の基準(WCAG 1.4.11 Non-text Contrast)で、これは文字ではなくボタンの枠線やアイコンなど「非テキスト要素」に対する基準だ。文字色のコントラストだけ気にして、ボタンのアウトラインの薄さを見落とすケースは実装していてもよくある。今回のツールでは文字用4基準とは別枠でこれも同時に表示するようにした。

テストではこの境界を明示的に確認している。

it('赤文字を白背景に置いた場合、AA大文字は合格するが通常文字は不合格になる(約4:1)', () => {
  const result = computeContrastRatio('#FF0000', '#FFFFFF')
  expect(result).not.toBeNull()
  expect(result!.ratio).toBeCloseTo(4.0, 1)
  expect(result!.aaLargeText).toBe(true)
  expect(result!.aaNormalText).toBe(false)
  expect(result!.aaaNormalText).toBe(false)
})

赤(#FF0000)と白の組み合わせは、一見はっきり見える配色に見えても通常文字のAA基準(4.5:1)には届かない。「読めなくはないが基準は満たさない」という中間のケースをテストに含めておくと、閾値の実装ミスに気づきやすい。

合格色の自動提案:黒 or 白へ向けた線形探索

判定するだけでなく、基準を満たさない場合に「この背景色ならこの文字色にすればいい」という提案も出している。

考え方はシンプルで、背景色に対してコントラストが確実に高くなる方向(黒か白か)を先に決め、その方向へ現在の文字色を少しずつ近づけながら、目標比率に達した時点の色を返す。

export function suggestAccessibleColor(
  foregroundHex: string,
  backgroundHex: string,
  targetRatio: number
): string | null {
  const fg = parseHexColor(foregroundHex)
  const bg = parseHexColor(backgroundHex)
  if (!fg || !bg) return null

  const bgLuminance = relativeLuminance(bg)
  const contrastWithBlack = contrastRatioFromLuminance(0, bgLuminance)
  const contrastWithWhite = contrastRatioFromLuminance(1, bgLuminance)
  const target: Rgb = contrastWithBlack >= contrastWithWhite
    ? { r: 0, g: 0, b: 0 }
    : { r: 255, g: 255, b: 255 }

  const steps = 100
  for (let step = 0; step <= steps; step++) {
    const t = step / steps
    const candidate: Rgb = {
      r: Math.round(fg.r + (target.r - fg.r) * t),
      g: Math.round(fg.g + (target.g - fg.g) * t),
      b: Math.round(fg.b + (target.b - fg.b) * t),
    }
    const ratio = contrastRatioFromLuminance(relativeLuminance(candidate), bgLuminance)
    if (ratio >= targetRatio || step === steps) {
      return rgbToHex(candidate)
    }
  }
  return rgbToHex(target)
}

100分割の線形補間で候補色を作り、目標比率を満たした最初の色を返す。二分探索の方が理論上は速いが、コントラスト比は補間パラメータ t に対して単調に変化するとは限らない場合があり、実装をシンプルに保つためあえて素朴な線形走査にしている。100ステップ程度ならブラウザ内の計算として体感できる遅延にはならない。

背景色によっては、黒にしても白にしても目標比率に届かないケースがある。中間的な明るさのグレー同士のように、そもそも21:1に対して届く上限が低い組み合わせだ。この場合はループが最後まで到達可能な最大コントラストの色(黒 or 白の極値)を返すようにしている。

it('中間グレー背景ではAAA基準(7)に届かない場合、到達可能な最大コントラストの色を返す', () => {
  const suggested = suggestAccessibleColor('#808080', '#808080', 7)
  expect(suggested).not.toBeNull()
  const result = computeContrastRatio(suggested!, '#808080')
  // 黒文字が中間グレー背景に対して到達できる最大コントラスト(約5.3)を超えられないことを確認
  expect(result!.ratio).toBeLessThan(7)
  expect(result!.ratio).toBeGreaterThan(5)
})

#808080(中間グレー)を背景にAAA基準(7:1)を求めても、黒文字での最大コントラストは約5.3にしかならない。「提案してもnullを返さず、可能な限り近い色を返す」という仕様は、UIとして「ボタンを押したのに何も変わらない」という体験を避けるための設計判断だ。

まとめ

コントラスト比の計算自体はWCAG仕様に沿って実装すれば難しくない一方、「複数の基準を同時に見せる」「境界値でどちらに転ぶかテストで固定する」「提案アルゴリズムが失敗しても何かしらの色を返す」といった細部の作り込みが、実際に使えるツールになるかどうかを分けると感じた。

ブラウザ完結で色コードが外部に送信されない設計にしているので、ブランドカラーやデザインカンプの配色をそのまま試せる。

ぱんだツールズ では他にも色コード変換・色覚シミュレーターなどデザイナー・エンジニア向けの色関連ツールを公開中。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?