Reactで、文字色と背景色のコントラスト比を表示する方法

​​React​Playwright,TypeScript,WCAG,アクセシビリティ

文字色と背景色を選ぶと「4.54:1」のようにコントラスト比を出し、WCAG の AA・AAA の基準を満たすかどうかを並べて表示する React のコンポーネントを作ります。比の計算は WCAG の式どおりの小さな関数にまとめ、表示は小数第2位で切り捨てます。判定は、切り捨てる前の値で行います。

色は、色の input(<input type="color">)のパレットでも、カラーコードを打つ入力欄でも選べるようにします。2つの欄を連動させるときに、入力途中の「#abc」が勝手に「#aabbcc」に書き換わらないようにする書き方も紹介します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、本番ビルドと、StrictMode を付けた開発ビルドの両方を Playwright で操作しました。どちらも同じ表示になり、React の警告やエラーは出ませんでした。色の input は Playwright の fill() で値を入れており、パレットの画面を開いて操作してはいません。透明度のある色、Firefox と Safari、スクリーンリーダーでの読み上げは試していません。

文字色の欄に #767676 と打つとコントラスト比が 4.54:1 になり、AAA の通常の文字だけが未達成になる。続けて #0b8299 と打つと 4.49:1 になり、AA の通常の文字と AAA の大きな文字も未達成になる。入れ替えボタンを押すと、プレビューの文字が白、背景が #0b8299 になり、比は 4.49:1 のまま
文字色を2回入力してから、入れ替えボタンを押したときの表示の変化。カーソル、押した場所の輪、右下の操作の回数は GIF 用に重ねたもの。カラーコードの欄、比、判定の欄は、値が変わった瞬間だけピンクの背景にしている。打ち始めの「#」だけの間は、欄の下に入力の形の案内が出る

相対輝度とコントラスト比を計算する関数

計算に使うのは、WCAG 2.2 の相対輝度とコントラスト比の定義にある式です。相対輝度は、RGB の各値を 0〜1 にしてから補正し、0.2126・0.7152・0.0722 の重みで足したものです。コントラスト比は、明るいほうの相対輝度を L1、暗いほうを L2 として、(L1 + 0.05) / (L2 + 0.05) で求めます。どちらの色が文字でも、同じ比になります(検証環境でも、#0b8299 と白を入れ替えて計算した比は同じでした)。

カラーコードの読み取りと、AA・AAA の基準も同じファイルに置きました。

ts
export type RGB = { r: number; g: number; b: number };

// 「#rgb」「#rrggbb」(# は省略可、大文字も可)を読む。それ以外は null を返す
export function parseHex(input: string): RGB | null {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(input.trim());
  if (!m) return null;
  const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
  const n = parseInt(h, 16);
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}

// 色の input と同じ、小文字の「#rrggbb」にそろえる
export function toHex({ r, g, b }: RGB): string {
  return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
}

// WCAG の相対輝度(sRGB の 0〜255 の値から)
function channel(v: number): number {
  const c = v / 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

export function relativeLuminance({ r, g, b }: RGB): number {
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

// 明るいほうを L1、暗いほうを L2 として (L1 + 0.05) / (L2 + 0.05)
export function contrastRatio(a: RGB, b: RGB): number {
  const la = relativeLuminance(a);
  const lb = relativeLuminance(b);
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}

// 表示は小数第2位までにする。四捨五入だと 4.499 が「4.50」と出てしまうので、切り捨てる
export function formatRatio(ratio: number): string {
  return `${(Math.floor(ratio * 100) / 100).toFixed(2)}:1`;
}

export const CRITERIA = [
  { id: 'aa-normal', level: 'AA', text: '通常の文字', min: 4.5 },
  { id: 'aa-large', level: 'AA', text: '大きな文字', min: 3 },
  { id: 'aaa-normal', level: 'AAA', text: '通常の文字', min: 7 },
  { id: 'aaa-large', level: 'AAA', text: '大きな文字', min: 4.5 },
] as const;

parseHex は「#rgb」と「#rrggbb」を受け付けます。# は省いてもよく、大文字でも読めます。それ以外の文字列には null を返します。検証環境で呼んだ結果は次のとおりです。

text
"#1f2a30" → {"r":31,"g":42,"b":48}
"1F2A30" → {"r":31,"g":42,"b":48}
"#abc" → {"r":170,"g":187,"b":204}
" #fff " → {"r":255,"g":255,"b":255}
"#abcd" → null
"#12345g" → null
"" → null
"#1f2a3" → null

相対輝度は、黒が 0、白が 1 になりました。#ff0000 の赤、#00ff00 の緑、#0000ff の青は、それぞれ重みと同じ 0.2126・0.7152・0.0722 です。黒と白のコントラスト比は 21、同じ色どうしは 1 でした。WCAG の定義にあるとおり、比は 1 から 21 までの値になります。

channel の中の 0.04045 は、古い WCAG の文書では 0.03928 でした。WCAG 2.2 の相対輝度の定義には、2021年5月より前は 0.03928 だったこと、この基準での計算には実際の影響がないことが書かれています。0〜255 のすべての値で両方の式を計算してみると、結果が違う値はありませんでした。255 で割った値は、10 のとき 0.0392…、11 のとき 0.0431… で、2つのしきい値の間に入る値がないためです。

text
違う値: 0 / 256
10/255 = 0.0392156862745098  11/255 = 0.043137254901960784

表示は切り捨て、判定は計算したままの値で行う

コントラスト比を小数第2位まで表示するとき、toFixed(2) で四捨五入すると、基準にわずかに届かない比が、基準ちょうどの値に見えることがあります。検証環境で、白い背景に対するいくつかの文字色を計算しました。

文字色 計算したままの比 四捨五入(toFixed) 切り捨て AA 通常の文字(4.5:1 以上)
#767676 4.5422… 4.54:1 4.54:1 達成
#777777 4.4780… 4.48:1 4.47:1 未達成
#0b8299 4.4994… 4.50:1 4.49:1 未達成

#0b8299 は、四捨五入すると「4.50:1」と出るのに、4.5:1 の基準を満たしていません。WCAG の 達成基準 1.4.3 の解説でも、基準の比はしきい値で、計算した値を丸めて比べないこと、たとえば 4.499:1 は 4.5:1 を満たさないことが書かれています。

そこで formatRatio は、100倍して Math.floor で切り捨ててから toFixed(2) で2桁にしています。表示が「4.49:1」なら、読む人も「4.5 に届かない」と分かります。判定(ratio >= c.min)には、表示用の文字列ではなく、計算したままの数値を使います。

ポイント

四捨五入した表示だと、#0b8299 と白の組み合わせは「4.50:1」と出ますが、AA の 4.5:1 は満たしていません。表示は切り捨てにして、判定は丸める前の値で行います。

色の input とカラーコードの欄を、1組の部品にする

文字色と背景色の欄は、同じ部品 ColorField を2つ並べて作ります。左に色の input、右にカラーコードを打つ入力欄を置きます。

tsx
import { useState } from 'react';
import { parseHex, toHex } from './contrast';

type Props = {
  id: string;
  label: string;
  value: string; // 小文字の「#rrggbb」
  onChange: (hex: string) => void;
};

export function ColorField({ id, label, value, onChange }: Props) {
  // 入力欄の文字は、途中の「#1f2」のような値も持てるように、別の state にする
  const [draft, setDraft] = useState(value);
  const [prevValue, setPrevValue] = useState(value);

  // 色の input や入れ替えボタンで色が変わったときは、入力欄の文字もその色にそろえる。
  // ただし、入力欄の文字がすでにその色を表しているとき(「#abc」と「#aabbcc」)は書き換えない
  if (value !== prevValue) {
    setPrevValue(value);
    const typed = parseHex(draft);
    if (!typed || toHex(typed) !== value) setDraft(value);
  }

  const valid = parseHex(draft) !== null;
  const errorId = `${id}-error`;

  return (
    <div className="color-field">
      <label htmlFor={id}>{label}</label>
      <div className="color-inputs">
        <input
          type="color"
          aria-label={`${label}をパレットから選ぶ`}
          value={value}
          onChange={(e) => onChange(e.target.value)}
        />
        <input
          id={id}
          type="text"
          value={draft}
          spellCheck={false}
          autoComplete="off"
          aria-invalid={!valid}
          aria-describedby={valid ? undefined : errorId}
          onChange={(e) => {
            setDraft(e.target.value);
            const rgb = parseHex(e.target.value);
            if (rgb) onChange(toHex(rgb));
          }}
        />
      </div>
      {!valid && (
        <p id={errorId} className="error">
          「#1f2a30」か「#fff」の形で入力してください
        </p>
      )}
    </div>
  );
}

親に渡す色(value)は、<input type="color"> の値と同じ、7文字の小文字の「#rrggbb」にそろえます。カラーコードの欄に「#FFF」と打っても、色の input には #ffffff が入ります。

カラーコードの欄の文字(draft)は、打っている途中の「#12」のような読めない文字も持てるように、親の色とは別の state にしています。読めない文字のときは親の色を変えず、aria-invalid と、aria-describedby で結び付けた案内の文を付けます。検証環境では、「#12」と打つと比は前の色のまま変わらず、パレットで色を選び直すと欄の文字がその色になって案内の文が消えました。

外から色が変わったら、欄の文字をそろえる

色の input で選んだときや、入れ替えボタンを押したときは、カラーコードの欄の文字も新しい色にそろえる必要があります。ここでは、React の useState のリファレンスにある、前のレンダーの値を state に残しておく書き方を使いました。前の value を prevValue に持っておき、描画中に違いを見つけたら、draft を書き換えます。リファレンスによると、描画中に呼ぶ set 関数は、このように条件の中に置き、前の値を更新する呼び出し(ここでは setPrevValue(value))も入れる必要があります。

ただし、そろえるたびに draft を書き換えると、カラーコードを打っている途中で文字が変わってしまいます。「#abcdef」と打つつもりで「#abc」まで打つと、3桁の形として読めるので、親の色が #aabbcc になります。その変化を受けて欄の文字まで #aabbcc に書き換わると、続けて打った「def」は後ろに付いてしまいます。

検証環境で、draft を毎回書き換える版と比べました。背景色の欄に「#abc」と打ってから、続けて「def」と打った結果です。

ColorField の書き方 「#abc」まで打ったときの欄 続けて「def」を打ったときの欄 背景色
draft が同じ色を表すときは書き換えない #abc #abcdef #abcdef
draft を毎回書き換える #aabbcc #aabbccdef #aabbcc のまま

毎回書き換える版では、欄が読めない文字になり、背景色も #abcdef になりませんでした。記事のコードでは、draft を読んだ色が新しい value と同じなら書き換えないので、打ったとおりの文字が残ります。

なお、6桁のカラーコードを1文字ずつ打つと、3文字目までで3桁の形として読まれ、比がいったん変わります。冒頭の GIF では、1回目は「#767」(#776677)の時点で 5.31:1 に、2回目は「#0b8」(#00bb88)の時点で 2.48:1 になってから、6桁を打ち終えた色の比に変わっています。

比と、AA・AAA の判定を並べる

2つの ColorField の値から比を計算し、プレビュー、比、判定の表を並べます。

tsx
import { useState } from 'react';
import { ColorField } from './ColorField';
import { contrastRatio, CRITERIA, formatRatio, parseHex } from './contrast';

export function ContrastChecker() {
  const [fg, setFg] = useState('#1f2a30');
  const [bg, setBg] = useState('#ffffff');

  // fg と bg には、ColorField が小文字の「#rrggbb」にそろえた色だけが入る
  const ratio = contrastRatio(parseHex(fg)!, parseHex(bg)!);

  return (
    <section className="checker">
      <div className="fields">
        <ColorField id="fg" label="文字色" value={fg} onChange={setFg} />
        <ColorField id="bg" label="背景色" value={bg} onChange={setBg} />
      </div>
      <button
        type="button"
        onClick={() => {
          setFg(bg);
          setBg(fg);
        }}
      >
        文字色と背景色を入れ替える
      </button>

      <div className="preview" style={{ color: fg, backgroundColor: bg }}>
        <p className="sample-normal">通常の文字のサンプル</p>
        <p className="sample-large">大きな文字のサンプル</p>
      </div>

      <p className="ratio">
        コントラスト比 <strong data-testid="ratio">{formatRatio(ratio)}</strong>
      </p>

      <table className="results">
        <thead>
          <tr>
            <th scope="col">基準</th>
            <th scope="col">必要な比</th>
            <th scope="col">結果</th>
          </tr>
        </thead>
        <tbody>
          {CRITERIA.map((c) => {
            // 基準と比べるときは、表示用に切り捨てる前の値を使う
            const ok = ratio >= c.min;
            return (
              <tr key={c.id}>
                <th scope="row">
                  {c.level} {c.text}
                </th>
                <td>{c.min}:1 以上</td>
                <td className={ok ? 'pass' : 'fail'}>{ok ? '○ 達成' : '× 未達成'}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </section>
  );
}

判定の基準は、WCAG の達成基準 1.4.3(AA)と1.4.6(AAA)の値です。AA は通常の文字で 4.5:1 以上、大きな文字で 3:1 以上、AAA は通常の文字で 7:1 以上、大きな文字で 4.5:1 以上です。WCAG の「大きな文字」の定義は、18ポイント以上か14ポイント以上の太字、または中国語・日本語・韓国語のフォントでそれに相当する大きさです。プレビューの大きな文字は、目安として CSS で font-size: 18pt にしています。

判定の結果は、色だけでなく「○ 達成」「× 未達成」の文字でも出します。WCAG の達成基準 1.4.1「色の使用」は、色だけで情報を伝えないことを求めています。

検証環境で操作したときの表示です。初期の #1f2a30 と白の組み合わせは 14.66:1 で、4つの基準をすべて満たしました。

文字色 / 背景色 表示した比 AA 通常の文字 AA 大きな文字 AAA 通常の文字 AAA 大きな文字
#1f2a30 / #ffffff 14.66:1 ○ ○ ○ ○
#767676 / #ffffff 4.54:1 ○ ○ × ○
#777777 / #ffffff 4.47:1 × ○ × ×
#0b8299 / #ffffff 4.49:1 × ○ × ×
#ffffff / #0b8299(入れ替え後) 4.49:1 × ○ × ×

入れ替えボタンを押すと、2つの欄の文字と色の input の値が入れ替わり、プレビューは白い文字に #0b8299 の背景になりました。コントラスト比の式は明るいほうと暗いほうで決まるので、入れ替えても比は同じです。入れ替えボタンは、比を確かめるためではなく、文字色と背景色を逆にしたときの見た目を、プレビューで確かめるために置いています。

​Posted by 86