動画で読む
結論 — コントラストの事故は「状態」と「テーマ」で起きる
カラーコントラストの事故は、静止した画面ではなく hover した瞬間やダークテーマに切り替えた瞬間に起きます。だからチェックも「要素 × 状態 × テーマ」の総当たりでやる必要がある、というのが今回の主旨です。axe-core を CI に入れていても「ダークにしたら文字が消えた」「ホバーで読めなくなった」がリリース後に見つかるのは、ツールが見ている範囲が狭いからです。Zenn の配色を全組み合わせで検証する記事が、その総当たり監査をブラウザの JavaScript だけでやる手法を示していて、私の OKLCH トークン + light/dark 運用にそのまま刺さったので書きます。
AetherEchoes(このブログ)の色は OKLCH ベースの意味付きトークン(paper / ink / rule / cat-*)で管理し、light と dark の 2 モードを持っています。トークンを足すたびに「この状態でこのテーマだと読めるか」を DevTools で 1 つずつ見ていて、これが地味に漏れる。元記事を読んで「漏れるのは見ていない状態だ」と整理できたのが収穫でした。なおこの記事は AI が下書きを書き、私が元記事と WCAG の一次資料に当たって確認・編集してから公開しています。
なぜ axe-core だけでは漏れるのか — スキャン時点の DOM しか見ない
axe-core や Pa11y は成熟したコントラスト検査ツールですが、根本的に「スキャンした瞬間の DOM 状態」しか見ないという限界があります。アクティブでない状態のコントラストは、どれだけ低くても素通りします。
ホバー中のボタン、フォーカスが当たった入力欄、選択中のタブ。これらは検査の瞬間にその状態でなければ評価されません。元記事によれば axe-core の GitHub には 2018 年からこの問題の issue が立っていて、「スタイルシートを操作して疑似クラスを強制適用する必要がある」とコメントされているものの、既定では今も解決されていないそうです。Playwright で「ホバー状態にしてから axe-core を走らせる」対処はありますが、全要素の全状態を網羅しようとするとテストコードが膨れ上がります。私も cat-* の active 表示を部門ごとに足したとき、状態の組み合わせを手で追いきれなくなりました。
実効背景色という第二の穴
もう一つの穴が「実効背景色」です。getComputedStyle(el).backgroundColor を取っても transparent が返ることが多く、半透明レイヤーが何枚も重なって初めて見た目の色が決まるため、本当の背景色は親を遡って合成しないと分かりません。
元記事の resolveEffectiveBg() は、要素からルートまで遡って不透明度を持つ背景を集め、アルファブレンディング(半透明色を下の色に重ねて合成する計算)で実際の描画色に近い値を出します。さらに Chrome の getComputedStyle は背景色を rgba() ではなく color(srgb 1 1 1 / 0.92) という形式で返すことがあり、これをパースできないと計算がスキップされる。その形式への対応も実装に含まれているとのことでした。私のサイトもヒーロー帯で paper の上に半透明の ink を重ねている箇所があり、ここは単純な背景色取得だと正しく測れません。実効背景色の合成は、地味ですが避けて通れない処理です。
3 層の監査 — 静的 CSS 差分 / 実効色合成 / 状態×テーマ総当たり
元記事の手法は、上の 2 つの穴を 3 層で埋めます。状態を再現する前に CSS の定義そのものを解析する層が、特に新しい発想でした。
1 層目は CSS ルールの静的差分解析です。DOM をレンダリングする前に、「background-color は変わるのに color が変わらないルール」を機械的に洗い出します。
// 状態変化ルールを走査し、片方だけ変わるケースを検出
if (bgChanged && !colorChanged && !colorInNormal) {
issues.push({ type: "bg変化・color未定義", ...v, state });
}
これは axe-core と根本的に違って、実際にその状態を再現しなくても問題の構造を指摘できます。.btn.active { background: #1a1a1a } で color の更新を忘れていれば、ボタンを選択状態にしなくても引っかかる。テーマ間で CSS 変数の定義が欠けているケースも事前に拾えます。2 層目が先ほどの実効背景色の合成、3 層目が「要素クラス × 状態(normal / hover / active)× テーマ(light / dark / high-contrast)」のマトリクス総当たりです。知っている要素だけ見る目視と違い、組み合わせを論理的に列挙して漏れをなくします。
コントラスト比の計算自体はブラウザだけで完結します。各色を相対輝度(人間の目が感じる明るさを数値化したもの)に変換し、明るい方に 0.05 を足して暗い方で割るだけです。
function relativeLuminance(r, g, b) {
const toLinear = (c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
const ratio = (l1, l2) => (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
比は 1
から 21 に収まります。WCAG(Web Content Accessibility Guidelines、Web アクセシビリティの国際指針)の基準と照合すれば、各ペアの合否が出ます。| 基準 | 通常テキスト | 大きなテキスト |
|---|---|---|
| AA | 4.5 | 3 |
| AAA | 7 | 4.5 |
OKLCH + dark mode 運用に当てる
私のように OKLCH でトークンを定義している場合、この計算は sRGB 前提なので、トークンの定義値ではなく「ブラウザが実際に描画した sRGB 値」を getComputedStyle で取り直してから計算するのが安全です。ここを飛ばすと数値がずれます。
OKLCH の落とし穴はOKLCH を実務で踏んだ 3 点で別にまとめましたが、P3 ガマット(sRGB より広い色域)の鮮やかな色を sRGB にクランプすると画面と計算がずれる、という罠がコントラスト検証でも顔を出します。描画後の値で測れば、その差を気にせず済む。運用としては、トークンや dark の配色をいじる PR のたびにこのスクリプトを開発環境で走らせる、という素朴な手順に落としました。グリッドはまず 8px からで書いたスペーシングの機械チェックと発想は同じで、機械に見張らせる対象を 1 つ増やしただけです。
正直に書くと、最初は Storybook のアドオンで同じことをやろうとして設定で 30 分溶かし、諦めました。コンソールに貼って動く JS なら 5 分です。私のコードレビューはたいてい厳しいのに、自分の配色は「なんとなく綺麗」で通そうとするので、数値で殴ってくれる道具はありがたい。本格運用なら Storybook のビジュアルリグレッションが正解の場面も多く、ここは規模との相談です。
限界 — 静的差分は強いが万能ではない
この手法には明確な限界があり、axe-core の代替ではなく補完として位置づけるのが正確です。過信は禁物です。
限界は 3 つ。1 つ目、グラデーション背景で偽陽性が出ます。linear-gradient(...) だと getComputedStyle().backgroundColor が透明を返し、親のダーク背景を実効背景と誤認してコントラストが不当に低く出る。FAIL はスクリーンショットで目視確認が要ります。2 つ目、クロスオリジンの iframe や CSP 制限があると styleSheets を読めず、静的解析が機能しません。自分のサイトか開発環境でのみ有効です。3 つ目、ホバー状態は今も手動再現が残ります。発展形として、コンポーネントを孤立させてコントラスト比をスナップショットし、変更のたびに差分を見る回帰テストの使い方も挙げられていました。axe-core は CI で継続監視、この手法は変更後に全状態をまとめて確認、と役割を分けるのが現実的です。
まとめ — 「状態 × テーマ」を機械に見張らせる
コントラスト検証を「要素 × 状態 × テーマ」の総当たりに広げ、それをブラウザ JS で完結させるのが今回の本質です。axe-core が見ないホバーやダークの状態を、静的 CSS 差分・実効背景色の合成・マトリクス走査の 3 層で埋める。
OKLCH を使うなら描画後の sRGB 値で測ること、グラデーションや擬似クラスには限界があること。この 2 点を押さえれば実用に持っていけます。派手な仕組みではありませんが、「なんとなく綺麗」を状態ごとに数値で裏打ちできるようになったのが、私には一番大きい変化でした。
Tags
よくある質問
- axe-core を使っていてもコントラスト事故が漏れるのはなぜですか?
- axe-core や Pa11y はスキャンした瞬間の DOM 状態しか見ないためです。hover・focus・active などアクティブでない状態や、切り替え前のテーマのコントラストは評価されず、ダークにした瞬間に文字が消えるといった問題がリリース後に見つかります。
- 実効背景色とは何で、なぜ合成が必要なのですか?
- 要素に実際に効いている背景色のことです。getComputedStyle は transparent を返すことが多く、半透明レイヤーが重なって初めて見た目の色が決まるため、親をルートまで遡ってアルファブレンディングで合成しないと本当の背景色が分かりません。
- WCAG のコントラスト比はどう計算しますか?
- 各色を sRGB から相対輝度(人間の目が感じる明るさ)に変換し、明るい方に 0.05 を足して暗い方の値で割って求めます。結果は 1:1 から 21:1 に収まり、通常テキストは AA で 4.5:1、AAA で 7:1 が基準です。OKLCH 運用では描画後の sRGB 値で計算するのが安全です。
- この手法の限界はどこですか?
- グラデーション背景は透明扱いになり偽陽性が出ること、クロスオリジン iframe や CSP では styleSheets を読めないこと、hover 状態の再現が手動で残ることです。axe-core の代替ではなく、CI 監視と変更後の全状態確認を役割分担する補完として使うのが現実的です。