Claude CodeでWCAGのコントラスト比不足をトークンごと直す手順
CSS変数の前景と背景の組を列挙して比を計算するスクリプトを書かせ、4.5:1未満の組だけトークン側で直し、ダークテーマも同じ検査に通す手順を、実行出力つきで示します。
WCAGのコントラスト比不足は、画面ごとに色を塗り直すより、CSS変数(デザイントークン)の側で直すほうが速く、再発もしにくくなります。コンポーネントのCSSには var(--text-muted) のような参照しか残っていないからです。
この記事では、前景色と背景色の組を contrast-pairs.json に宣言し、比を計算して4.5:1未満を洗い出すスクリプトをClaude Codeに書かせます。直すのはトークンの値だけで、ダークテーマも同じ検査に通します。最後に、Stop hookで「検査が通るまで終わらせない」状態にするところまでが範囲です。
検査する基準は3つの数字で足りる
WCAG 2.2の達成基準1.4.3 Contrast (Minimum)はLevel AAで、文字と文字画像のコントラスト比を4.5:1以上と定めています。例外のうち、大きい文字は3:1以上です。無効なUI部品の文字、純粋な装飾、ロゴタイプには要件がありません。
もう1つ、1.4.11 Non-text ContrastもLevel AAです。UI部品とその状態を見分けるのに必要な視覚情報(入力欄の枠線など)と、内容の理解に必要な図形の部品は、隣り合う色に対して3:1以上が要ります。
比は次の式で決まります。L1が明るいほう、L2が暗いほうの相対輝度です。
コントラスト比 = (L1 + 0.05) / (L2 + 0.05)相対輝度は、sRGBの各チャンネルを255で割り、0.04045以下なら12.92で割り、それより大きければ ((v + 0.055) / 1.055) ^ 2.4 を取って、0.2126 * R + 0.7152 * G + 0.0722 * B で合成した値です。比は1:1から21:1の範囲になります。
スクリプトに持たせる基準をまとめると、次の3種類です。
| 種別 | 対象 | 必要な比 | 根拠 |
|---|---|---|---|
text | 対象通常の文字 | 必要な比4.5:1 | 根拠1.4.3 |
large | 対象大きい文字 | 必要な比3:1 | 根拠1.4.3の例外 |
ui | 対象入力欄の枠線などの部品 | 必要な比3:1 | 根拠1.4.11 |
「大きい文字」は18ポイント以上、または太字の14ポイント以上です。日本語など中国語・日本語・韓国語のフォントでは、同等の大きさになるフォントサイズが基準です。迷う箇所は large にせず text で検査しておくと、安全側に倒れます。
検査の単位は色ではなく「組」
トークンは色の一覧にすぎず、比は前景と背景の組み合わせでしか決まりません。--text-muted がAAに通るかどうかは、乗る背景が --bg か --surface かで変わります。
そこで、検査したい組を人が先に宣言します。contrast-pairs.json を置きます。
[
{ "fg": "--text", "bg": "--bg", "kind": "text" },
{ "fg": "--text", "bg": "--surface", "kind": "text" },
{ "fg": "--text-muted", "bg": "--bg", "kind": "text" },
{ "fg": "--text-muted", "bg": "--surface", "kind": "text" },
{ "fg": "--link", "bg": "--bg", "kind": "text" },
{ "fg": "--on-accent", "bg": "--accent", "kind": "text" },
{ "fg": "--border-input", "bg": "--bg", "kind": "ui" }
]検査対象のトークン定義は、ライトを :root、ダークを [data-theme="dark"] に置いた次のような形を想定します。
:root {
--bg: #ffffff;
--surface: #f4f5f7;
--text: #1f2933;
--text-muted: #8a94a0;
--link: #3b82f6;
--accent: #e8590c;
--on-accent: #ffffff;
--border-input: #c9ced6;
}
[data-theme="dark"] {
--bg: #12161c;
--surface: #1c222b;
--text: #e6e9ee;
--text-muted: #6b7684;
--link: #5b9dff;
--accent: #ff8a4c;
--on-accent: #ffffff;
--border-input: #3a4350;
}どれも手を入れる前の値です。見た目には読めそうな色が並んでいますが、数字にすると結果が割れます。
Claudeに検査スクリプトを書かせる
プロンプトには、基準の数字とテーマの構造、そして「直すのはトークンだけ」という制約を入れます。公式のワークフロー集には、Claudeの出力をほかのツールと同じようにスクリプトへつなぐ使い方があります。ここでも、検査はClaudeの判断ではなくコマンドの終了コードに任せます。
scripts/check-contrast.mjs を作って。依存パッケージなし、Node標準のみ。
- 引数1: トークンのCSS、引数2: contrast-pairs.json
- :root をライト、[data-theme="dark"] をダークとして読む。ダークに無い変数はライトの値を使う
- 比はWCAG 2.2の相対輝度の定義どおりに計算する
- kind は text=4.5、large=3、ui=3 で判定。1組でも落ちたら exit 1
- 出力は1行1組、テーマ名・前景・背景・比・必要比を並べる
色の指定が #rgb / #rrggbb 以外なら黙って通さず、エラーで止めて。返ってくるコードの細部は実行のたびに違ってかまいません。判定の核は次の部分です。定義どおりに書けているかは、式の部分を上の式と読み比べて確かめられます。
// WCAG 2.2 の相対輝度の定義どおり
function luminance(hex) {
const [r, g, b] = toRgb(hex).map((v) =>
v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function ratio(a, b) {
const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (l1 + 0.05) / (l2 + 0.05);
}表示は小数第2位に丸めますが、合否の判定は丸める前の値で行います。4.496が「4.50」と表示されて通ったように見える事故を避けるための、このスクリプト側の設計です。
まず落ちる組を数える
手を入れる前に、現状を測ります。
node scripts/check-contrast.mjs src/styles/tokens.css contrast-pairs.json上のトークンとペアで、Node 20.18.0で実行した出力です(FAIL の行が不足)。
OK light --text on --bg 14.76:1 (必要 4.5:1)
OK light --text on --surface 13.53:1 (必要 4.5:1)
FAIL light --text-muted on --bg 3.08:1 (必要 4.5:1)
FAIL light --text-muted on --surface 2.82:1 (必要 4.5:1)
FAIL light --link on --bg 3.68:1 (必要 4.5:1)
FAIL light --on-accent on --accent 3.58:1 (必要 4.5:1)
FAIL light --border-input on --bg 1.58:1 (必要 3:1)
OK dark --text on --bg 14.91:1 (必要 4.5:1)
OK dark --text on --surface 13.14:1 (必要 4.5:1)
FAIL dark --text-muted on --bg 3.93:1 (必要 4.5:1)
FAIL dark --text-muted on --surface 3.46:1 (必要 4.5:1)
OK dark --link on --bg 6.66:1 (必要 4.5:1)
FAIL dark --on-accent on --accent 2.34:1 (必要 4.5:1)
FAIL dark --border-input on --bg 1.81:1 (必要 3:1)14組のうち10組が落ちています。両テーマで落ちるのは --text-muted、--on-accent、--border-input の3つです。--link はライトだけが落ち、--on-accent はダークの2.34:1が最も低い値です。
ここで気づくのは、--text のように主役の色は余裕で通り、補助的な色ほど落ちるという偏りです。控えめに見せたい意図で薄くした色が、そのまま不足になっています。
不足の組だけトークン側で直す
次に、落ちた組だけを直させます。コンポーネントのCSSやマークアップには触らせません。
node scripts/check-contrast.mjs src/styles/tokens.css contrast-pairs.json
が FAIL を返している。tokens.css の変数の値だけを変えて全組を通して。
- 変えてよいのは FAIL の組に出てくる変数だけ。通っている変数(--text, --bg, --surface)は触らない
- 色相は保つ。明るさだけ動かして、必要な比を少し超えるところで止める
- src/components 配下のCSSは編集しない
終わったら同じコマンドの出力を、省略せずそのまま見せて。最後の一文が効きます。Claude Codeのベストプラクティスにも、「テストが通った」という言葉だけでは情報が少なく、実行したコマンドとその出力を求めるべきだとあります。出力を読めば、全組を自分の目で再確認できます。
この条件のもとで、トークンの値は次のように動かせます。例として選んだ値で、公式の推奨値ではありません。
:root {
- --text-muted: #8a94a0;
- --link: #3b82f6;
- --accent: #e8590c;
- --border-input: #c9ced6;
+ --text-muted: #5b6573;
+ --link: #1d5fd1;
+ --accent: #b84305;
+ --border-input: #757d89;
}
[data-theme="dark"] {
- --text-muted: #6b7684;
- --accent: #ff8a4c;
- --border-input: #3a4350;
+ --text-muted: #98a2b0;
+ --accent: #b84305;
+ --border-input: #6f7a89;
}再実行すると、14組すべてが通ります。
OK light --text-muted on --bg 5.91:1 (必要 4.5:1)
OK light --text-muted on --surface 5.42:1 (必要 4.5:1)
OK light --link on --bg 5.82:1 (必要 4.5:1)
OK light --on-accent on --accent 5.46:1 (必要 4.5:1)
OK light --border-input on --bg 4.16:1 (必要 3:1)
OK dark --text-muted on --bg 7.03:1 (必要 4.5:1)
OK dark --text-muted on --surface 6.19:1 (必要 4.5:1)
OK dark --on-accent on --accent 5.46:1 (必要 4.5:1)
OK dark --border-input on --bg 4.17:1 (必要 3:1)変更したのは7つの値です。ダークの --text-muted は逆に明るく動かしている点に注意してください。ダークは背景が暗いので、前景を明るくすれば比が上がります。ライトとダークで動かす向きが逆になるため、片方のテーマだけで検査すると、もう片方を直し損ねます。
直す値は、最も厳しい背景の組で決まる
--text-muted は --bg と --surface の2つの背景に乗ります。直す前の比は --bg で3.08:1、--surface で2.82:1でした。同じトークンが複数の背景に乗るときは、比が低いほうの背景が値を決めます。--bg だけを見て4.5:1に届く色を選ぶと、--surface の上で落ちます。
修正後の値で見ても、--surface の5.42:1のほうが --bg の5.91:1より低く、余裕の幅は背景ごとに違います。プロンプトで「必要な比を少し超えるところで止める」と指定したのは、色を濃くしすぎて --text との差が潰れ、階層の見分けがつかなくなるのを避けるためです。
ホバーやフォーカス、選択中といった状態の色も、別のトークンなら別の組として宣言します。通常時だけ検査して状態色を入れ忘れると、押した瞬間にだけ読めない文字が残ります。
落とし穴: 検査が通っても保証されない範囲
先ほどの修正で、ダークの --accent をライトと同じ #b84305 に揃えました。--on-accent(白)との組は5.46:1で通っています。ところが、この色が暗い背景の上で文字として使われると話が変わります。
#b84305 と、ダークの --bg(#12161c)の比は3.32:1です。3:1が必要なUI部品としては通り、4.5:1が必要な文字としては落ちます。しかも、--accent と --bg の組を contrast-pairs.json に書いていなければ、検査は何も言いません。
検査が見るのは、宣言された組だけです。
実務では、次の点を押さえておくと取りこぼしが減ります。
- 新しいトークンや新しい使い方(アクセント色をリンク文字にするなど)を足したら、組も同じコミットで足す
- 無効状態(
disabled)の文字は、1.4.3の例外として要件がありません。組に入れずにおくと、不要な失敗を避けられます - 背景が画像・グラデーション・半透明の箇所は、この方式では測れません。スクリプトは
#rgbと#rrggbb以外をエラーで止めるので、見落とさずに済みます - 大きい文字の
large(3:1)を使うのは、サイズが保証できる見出しだけにします
組の過不足は、人がレビューする場面です。Claudeに「このリポジトリで文字を描画しているCSSを洗い、color と background の組でcontrast-pairs.jsonに無いものを列挙して」と頼む手があります。列挙は任せ、基準に入れるかどうかの判断は自分で行います。
毎回通すために: CLAUDE.mdとStop hook
検査は一度通しただけでは、次のトークン変更で簡単に壊れます。手を打つ場所は2つあります。
1つ目はCLAUDE.mdです。ベストプラクティスによると、リポジトリに検証手段があるなら、CLAUDE.mdで最も役に立つ1行はそれを実行するコマンドです。
## コントラスト検査
- 色のトークン(src/styles/tokens.css)を変えたら、必ず次を実行する
node scripts/check-contrast.mjs src/styles/tokens.css contrast-pairs.json
- 新しい前景色・背景色の使い方を足したら contrast-pairs.json にも組を足すただし、CLAUDE.mdの記述は、ほとんどの場合守られても、毎回守られるとは限りません。公式の言い方では、例外を許さない規則は設定側(フック)に移すべきものです。
2つ目がStop hookです。Claudeが応答を終えようとするたびにスクリプトを走らせ、終了コード2を返すと、Claudeは作業に戻されます。標準エラーに出した内容が「まだ終われない理由」として返るので、検査の出力を 1>&2 でそちらへ回します。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node scripts/check-contrast.mjs src/styles/tokens.css contrast-pairs.json 1>&2 || exit 2"
}
]
}
]
}
}これは .claude/settings.json に置きます。形は公式の型チェックの例(pnpm tsc --noEmit 1>&2 || exit 2)と同じです。
注意点が2つあります。このフックはトークンと無関係な作業の終わりにも走ります。スクリプトは依存なしで軽く作ってあるので影響は小さく、トークン側に既存の不足が残っているリポジトリでは、先に直してから有効にしてください。ブロックが連続して8回に達すると、Claude Codeが次のブロックを無視してターンを終えます。上限は CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変えられます。仕組みはStop hookのブロック上限の記事で扱っています。
どこまでをこの検査に任せるか
この方式が得意なのは、色のトークンを入口にした静的な検査です。実際の画面で背景と重なる箇所や、キーボード操作、スクリーンリーダーの挙動は見えません。監査レポートまで出したいならaccessibility-reviewスキルによるWCAG 2.1 AA監査、ブラウザ上の実測をPRで落としたいならPlaywrightとaxeのアクセシビリティCIが向きます。
Claude Code自身の画面の配色を色覚多様性に合わせたいときは、別の話です。それはthemeコマンドの設定にあたり、ここで直したのはあなたのプロダクトのCSSです。
まとめ
コントラスト比の不足は、色単体ではなく前景と背景の組で決まります。組を宣言し、計算をスクリプトに固定し、直すのはトークンの値だけにする。この3点で、Claudeに任せる範囲と人が決める範囲が分かれます。
最後の仕上げは、組の追加漏れを防ぐ運用です。トークンを足す変更では、組の追加もレビューの対象にしてください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。