Claude CodeでWCAG 2.2のターゲットサイズとフォーカス隠れをPlaywrightで測る
WCAG 2.2で加わった2.5.8(24px)と2.4.11(フォーカスが隠れない)を、Playwrightのスクリプトで測る手順です。結果をClaude Codeに読ませて直させる流れも組みます。
WCAG 2.2で加わったAAの基準のうち、画面を測れば判定できるものが2つあります。2.5.8のターゲットサイズ(最小)と、2.4.11のフォーカスが隠されない(最小)です。前者はポインターで押す対象が24×24 CSSピクセル以上あるか、後者はTabで着いた要素が固定ヘッダーなどに完全には隠れていないかを問います。
どちらも、ボタンの大きさと「いまフォーカスがある要素がどこに描かれているか」という数値だけで判定の大半が済みます。Playwrightで測るスクリプトを書き、結果をClaude Codeに渡して直させる構成にすれば、2.1までの検査では見えなかった違反をPRで拾えます。
2.5.8と2.4.11は何を満たせば合格か
測る前に、基準の文面を押さえます。W3Cの仕様書(WCAG 2.2)にある文言は次のとおりです。
| 基準 | レベル | 要求 |
|---|---|---|
| 2.5.8ターゲットサイズ(最小) | レベルAA | 要求ポインター入力の対象は24×24 CSSピクセル以上。ただし例外あり |
| 2.4.11フォーカスが隠されない(最小) | レベルAA | 要求フォーカスを受けた部品が、作成者の置いたコンテンツに完全には隠れない |
| 2.4.12フォーカスが隠されない(拡張) | レベルAAA | 要求フォーカスを受けた部品のどの部分も隠れない |
2.5.8には5つの例外があります。スクリプトで判定できるのは最初の1つだけで、残りは人が見ます。
- 間隔: 24px未満の対象でも、その外接矩形の中心に直径24pxの円を置いたとき、円が他の対象や、他の小さな対象の円と交わらなければ合格です
- 同等: 同じページに、同じ機能を果たす基準適合の別の部品がある
- インライン: 文中にある、または行の高さに大きさが制約される対象
- ユーザーエージェント制御: 大きさをブラウザーが決め、作成者が変えていない
- 本質的: その表示が情報の伝達に不可欠、または法的に求められる
2.4.11は文面が「完全には隠れない」です。一部でも見えていれば2.4.11は満たされ、全体が見えることまで求めるのは2.4.12(AAA)です。また注として、ユーザーが開いたコンテンツがフォーカス要素を覆う場合、フォーカスを動かさずに見える状態へ戻せるなら、隠れたとは数えないとされています。
数字の境目は覚えやすい形です。24pxはAAの最低線で、44pxは2.5.5(AAA)の水準になります。2.5.5とのレベルの食い違いは、accessibility-reviewスキルの解説で扱っています。
axe-coreのtarget-sizeと自前スクリプトの分担
axe-coreには target-size という規則があります。規則一覧では、タグは wcag22aa と wcag258、結果は「failure」と「needs review」の2種です。つまりaxeは2.5.8を検査でき、確信が持てないものを要確認に回します。既存の構成なら、withTags() に wcag22aa を足すだけで走ります。足し方はPlaywrightとaxeのCI記事にあります。
では自前で測る意味はどこにあるのか。次の3点です。
- 規則一覧で
wcag22aaタグを持つ規則はtarget-sizeの1つだけです。2.4.11に対応する規則は載っていないので、フォーカスの隠れは別の方法で測ります - axeの結果は「規則IDとセレクタ」で、どの要素がどの隣と近すぎるかまでは短く出ません。自前なら、違反の相手まで一行に出せます
- 測り方が見えているので、例外の扱い(インライン、既知の許容)を自分のプロジェクトに合わせて変えられます
おすすめの分担は、axeを広く浅い網として残し、自前の2本を「数値で説明できる補助」として足す形です。axeの needs review と自前の結果が食い違ったら、その要素を人が見ます。
ターゲットサイズを測る(2.5.8)
測る対象は、クリックやタップで押す要素です。a[href]・button・フォーム部品・role で押せることを示した要素・tabindex で操作対象にした要素を拾います。大きさは、ブラウザーが計算した矩形で見ます。
Playwrightには locator.boundingBox() があり、要素の矩形をメインフレームのビューポート基準で返します(見えない要素は null)。ここでは全要素の矩形と近接関係をまとめて計算したいので、ページの中で getBoundingClientRect() を一括で呼びます。
間隔の例外は、小さい対象Aについて次の順で判定します。
- 他の対象Bが24px以上なら、Aの中心からBの矩形までの距離が12px未満で「近すぎる」
- Bも24px未満なら、2つの中心間の距離が24px未満で「近すぎる」
- 親子関係にある要素どうしは数えない
// tests/a11y/wcag22/target-size.spec.ts
import { test, expect } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
const PAGES = ['/', '/pricing', '/login'];
for (const path of PAGES) {
test(`2.5.8 target size: ${path}`, async ({ page }) => {
await page.goto(path);
await page.getByRole('main').waitFor();
const result = await page.evaluate(() => {
const SELECTOR = [
'a[href]', 'button', 'input:not([type=hidden])', 'select', 'textarea',
'summary', '[role=button]', '[role=link]', '[role=checkbox]',
'[role=tab]', '[role=menuitem]', '[tabindex]:not([tabindex="-1"])',
].join(',');
const MIN = 24;
const name = (el: Element) => {
const id = el.id ? `#${el.id}` : '';
const cls = typeof el.className === 'string' && el.className.trim()
? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.')
: '';
return el.tagName.toLowerCase() + id + cls;
};
const items = [...document.querySelectorAll(SELECTOR)].flatMap((el) => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
if (r.width === 0 || r.height === 0 || cs.visibility === 'hidden') return [];
const hasSiblingText = [...(el.parentNode?.childNodes ?? [])]
.some((n) => n.nodeType === 3 && (n.textContent ?? '').trim() !== '');
return [{ el, r, sel: name(el), inline: cs.display === 'inline' && hasSiblingText }];
});
const small = (i: (typeof items)[number]) => i.r.width < MIN || i.r.height < MIN;
const center = (i: (typeof items)[number]) => ({
x: i.r.x + i.r.width / 2,
y: i.r.y + i.r.height / 2,
});
const failures: unknown[] = [];
const inlineReview: unknown[] = [];
let passedBySpacing = 0;
for (const s of items.filter(small)) {
const c = center(s);
const crowdedBy = items
.filter((o) => o.el !== s.el && !o.el.contains(s.el) && !s.el.contains(o.el))
.filter((o) => {
if (small(o)) {
const oc = center(o);
return Math.hypot(c.x - oc.x, c.y - oc.y) < MIN;
}
const dx = Math.max(o.r.left - c.x, 0, c.x - o.r.right);
const dy = Math.max(o.r.top - c.y, 0, c.y - o.r.bottom);
return Math.hypot(dx, dy) < MIN / 2;
})
.map((o) => o.sel);
const row = {
sel: s.sel,
size: `${s.r.width.toFixed(1)}x${s.r.height.toFixed(1)}`,
crowdedBy: crowdedBy.slice(0, 3),
};
if (crowdedBy.length === 0) passedBySpacing++;
else if (s.inline) inlineReview.push(row);
else failures.push(row);
}
return { total: items.length, passedBySpacing, failures, inlineReview };
});
mkdirSync('a11y-report', { recursive: true });
writeFileSync(
`a11y-report/target-size-${path.replace(/\W/g, '_')}.json`,
JSON.stringify(result, null, 2),
);
expect(result.failures, '24px未満で、間隔の例外も成り立たない対象').toEqual([]);
});
}出力は、違反が出たときだけ人間とClaudeが読める短さになります。次のような形です(要素名と数値はページ次第で変わる例です)。
{
"total": 41,
"passedBySpacing": 3,
"failures": [
{ "sel": "button.icon-close", "size": "16.0x16.0", "crowdedBy": ["button.icon-edit"] }
],
"inlineReview": []
}読み方は単純です。failures は、小さく、しかも隣と近すぎる対象です。passedBySpacing は、小さいが周囲に十分な余白があって間隔の例外で通っている数で、増減の目安になります。inlineReview は文中のリンクで、インライン例外に当たる可能性が高いので落とさず、目で見ます。
Tabで辿ってフォーカスの隠れを測る(2.4.11)
こちらは「フォーカスを当てた直後の画面」を測ります。静的に要素を数えても、固定ヘッダーとの重なりは分かりません。ブラウザーはTabで着いた要素をスクロールして見える位置へ運びますが、運んだ先が固定ヘッダーの裏なら、要素は隠れます。
手順は、Tabを押し、スクロールが落ち着くのを待ち、フォーカス中の要素の矩形を縦横5点ずつ(計25点)サンプルして、各点の最上面の要素が自分自身か子孫かを調べます。最上面の要素を引く document.elementFromPoint() は、ビューポート座標を取り、pointer-events: none の要素を無視します。ビューポートの外の点は null になるので、画面外も「見えていない」側に数えます。
// tests/a11y/wcag22/focus-not-obscured.spec.ts
import { test, expect } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
const PAGES = ['/', '/pricing', '/login'];
const VIEWPORTS = [
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'mobile', width: 390, height: 844 },
];
for (const vp of VIEWPORTS) {
for (const path of PAGES) {
test(`2.4.11 focus not obscured: ${vp.name} ${path}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(path);
await page.getByRole('main').waitFor();
await page.evaluate(() => { (window as any).__seen = new Set<Element>(); });
const hidden: unknown[] = [];
const partial: unknown[] = [];
for (let i = 0; i < 300; i++) {
await page.keyboard.press('Tab');
// スクロールの完了を2フレーム待つ
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
const r = await page.evaluate(() => {
const a = document.activeElement;
const seen = (window as any).__seen as Set<Element>;
if (!a || a === document.body || seen.has(a)) return { done: true as const };
seen.add(a);
const box = a.getBoundingClientRect();
const fr = [0.1, 0.3, 0.5, 0.7, 0.9];
let visible = 0;
let cover = '';
for (const fy of fr) for (const fx of fr) {
const x = box.left + box.width * fx;
const y = box.top + box.height * fy;
const inside = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight;
const hit = inside ? document.elementFromPoint(x, y) : null;
if (hit && a.contains(hit)) visible++;
else if (hit && !cover) cover = hit.tagName.toLowerCase() + (hit.id ? `#${hit.id}` : '');
}
const id = a.id ? `#${a.id}` : '';
return { done: false as const, sel: a.tagName.toLowerCase() + id, visible, cover };
});
if (r.done) break;
if (r.visible === 0) hidden.push(r);
else if (r.visible < 25) partial.push(r);
}
mkdirSync('a11y-report', { recursive: true });
writeFileSync(
`a11y-report/focus-${vp.name}-${path.replace(/\W/g, '_')}.json`,
JSON.stringify({ hidden, partial }, null, 2),
);
expect(hidden, 'フォーカスを受けたのに全体が隠れた要素').toEqual([]);
});
}
}落とすのは hidden(25点すべてが隠れた)だけです。partial は一部が隠れた状態で、2.4.11の文面では適合ですが、2.4.12(AAA)では不適合になります。レポートには出し、PRを止めない設計にしました。
固定ヘッダーが原因なら、cover に header などの名前が出ます。モバイル幅で出やすいのは、ヘッダーが2段になったり、画面下の固定バーが加わったりして、覆う面積が増えるからです。ビューポートを2つ回す理由はそこにあります。
結果をClaude Codeに読ませて直させる
測定が短いJSONを吐くので、直す側は claude -p で足ります。非対話モードの -p は、--allowedTools で承認なしに使えるツールを指定できます。許可したのは、結果の読み取り、ソースの編集、テストの再実行だけです。Bash(npx playwright test *) の末尾の * は前方一致で、空白を落とすと別のコマンドにも一致するため、空白を入れておきます。
#!/usr/bin/env bash
# scripts/fix-wcag22.sh
for round in 1 2 3; do
if npx playwright test tests/a11y/wcag22 --reporter=line; then
echo "WCAG 2.2の2検査に合格"; exit 0
fi
claude -p "tests/a11y/wcag22 が落ちています。a11y-report/*.json を読み、
次の方針で修正して。
- 2.5.8: failures の要素を、min-width/min-height 24px 以上にするか、
隣との間隔を空ける。見た目が変わる場合は変更点を要約に書く
- 2.4.11: hidden の要素が隠れる原因を CSS で直す。
対象のテストや期待値は変更しない
- 修正後に npx playwright test tests/a11y/wcag22 で再確認する" \
--allowedTools "Read,Edit,Bash(npx playwright test *)" \
--output-format json | jq -r '.result'
done
exit 1--output-format json にすると応答はJSONで返り、jq -r '.result' で本文だけを取り出せます。同じJSONには total_cost_usd も入るので、ラウンドごとの費用の推定にも使えます(ただし推定値で、請求額とずれる場合があります)。3ラウンドで止めているのは、直らない違反で無限に回さないためです。
実行環境ごとの注意点が1つあります。-p の実行は、プロジェクトの .claude/settings.json のhooksを読み、.mcp.json のサーバーにも接続します。CIで使うなら --bare を付けて、ホストの設定やCLAUDE.mdの自動読み込みを避けます。--bare ではOAuthの認証情報を読まないため、ANTHROPIC_API_KEY を環境変数で渡します。プロジェクト固有の指示は、プロンプトか --append-system-prompt-file に入れます。
普段のローカル実行ではCLAUDE.mdが効くので、毎回のプロンプトに書かない規約はそちらへ寄せます。
## アクセシビリティ(WCAG 2.2)
- クリック対象は24×24 CSSピクセル以上。小さいアイコンボタンは
padding で当たり判定を広げ、見た目のアイコンは変えない
- 固定ヘッダーやバナーを足すときは、同じ高さを scroll-padding-top に入れる
- tests/a11y/wcag22 の期待値(24、25点)を緩めて通さない直し方の定番2つ
測定で出る違反の大半は、次の2つで片付きます。
ターゲットサイズ。見た目を変えずに当たり判定だけ広げるのが、デザインとの衝突が少ない直し方です。
アイコンボタンの当たり判定
16pxのアイコンがそのままボタン
幅と高さがアイコンの16pxのまま。隣のボタンと8px間隔で並び、間隔の例外も成り立たない。
padding で24px以上に
padding を足して幅・高さを24px以上にする。アイコンの描画サイズは16pxのままで、見た目はほぼ変わらない。
.icon-button {
min-width: 24px;
min-height: 24px;
padding: 4px; /* 16pxのアイコン + 両側4px = 24px */
}隣との間隔を空ける方法もあります。ただし、24px円どうしが交わらない距離を確保する必要があり、狭いツールバーでは大きくするほうが早くなります。
フォーカスの隠れ。固定ヘッダーの高さを scroll-padding-top に入れます。このプロパティは、スクロールポートの中で「要素を見せるときの有効な領域」の上端を内側へずらし、固定ツールバーなどに覆われた領域を除外するものです。
html {
scroll-padding-top: 72px; /* 固定ヘッダーの高さ */
}ヘッダーの高さがメディアクエリで変わるなら、同じ場所で値も切り替えます。高さを変数で持てば、ヘッダー側とずれる事故が減ります。
落とし穴
見た目を直しても、測定の期待値を緩めると意味がなくなる
Claudeに「テストを通して」とだけ頼むと、24を20に変える、検査対象のページから問題の要素を外す、といった近道が候補になりえます。プロンプトとCLAUDE.mdに「期待値と対象ページは変えない」と書き、PRの差分でテストファイル自体が変わっていないかを確認します。ベースラインの書き換えを止めるフックの組み方は、axe記事のフックの節にあります。
機械で判定できない部分と、誤検知の出どころ
このスクリプトは、2つの基準の全部を判定するものではありません。結果を読むときの限界を挙げます。
- 例外の判断: 同等・ユーザーエージェント制御・本質的の3つは判定していません。ブラウザー標準のチェックボックスのように、作成者が大きさを変えていない部品は、
failuresに出ても例外に当たる場合があります - インライン判定は粗い: 文中のリンクの判定は、「兄弟にテキストノードがある
display: inlineの要素」という簡易な条件です。リストの中のリンクのように、文の外にあるものはfailuresに出ます - 覆い隠すものの種類:
elementFromPoint()はpointer-events: noneの要素を無視します。操作を通す透明な固定バナーは、見た目では隠れていても測定上は隠れていない扱いになります - ユーザーが開いたもの: ドロップダウンなど、利用者が開いたコンテンツがフォーカス要素を覆う場合の扱いは、2.4.11の注にあるとおり、動かさずに戻せるかで決まります。ここは人が判断します
- ページ全体の状態: ログインが要る画面は、認証済みの状態を使い回す設定が前提です。手順はstorageStateの記事にあります
- キーボードで届かない要素: Tabで到達できない要素は、そもそもこの測定の対象外です。到達順や操作可否は、キーボード操作の監査記事の手順で別に確認します
数字の誤差にも注意します。WCAGの24pxはCSSピクセルで数えるので、ズームしても判定の数値は変わりません。ただしズームでビューポートが狭くなるとレイアウトが組み替わり、ボタンの並びや固定ヘッダーの高さが変わることがあります。拡大時の表示は手で確かめます。
使い分けと運用の置き場所
毎回のPRで回すなら、axeの wcag22aa タグと、この2本の自前検査を同じジョブに置くのが現実的です。実行時間は、対象ページ数×ビューポート数に比例するので、全画面を毎回回すより、主要な導線とデザインシステムのコンポーネント一覧(Storybookなど)に絞ります。
役割の整理は次のとおりです。
| 手段 | 向く用途 | 見えないもの |
|---|---|---|
axeの target-size | 向く用途2.5.8の広い網。PRの合否 | 見えないもの2.4.11、例外の最終判断 |
| 自前のターゲットサイズ測定 | 向く用途違反の相手まで特定、独自の許容 | 見えないもの同等・本質的などの例外 |
| 自前のフォーカス隠れ測定 | 向く用途2.4.11の固定ヘッダー被り | 見えないものユーザーが開いた領域の扱い |
| 手動確認 | 向く用途例外の判断、実機での操作 | 見えないもの数が多いと追えない |
WCAG 2.1 AAの監査を一通り回す場合は、accessibility-reviewスキルから入ると、基準の全体像を先に掴めます。この記事の2本は、そこに載らない2.2の追加基準を数値で埋める位置づけです。
まとめ
2.5.8は「24px未満の対象が、隣と近すぎないか」、2.4.11は「Tabで着いた要素の全体が隠れていないか」を数えれば、機械で判定できる範囲が決まります。前者は矩形と中心間の距離、後者はTab後の elementFromPoint() のサンプルで足ります。
Claude Codeに渡すのは、違反の要素と相手だけを書いた短いJSONです。直させるときの最大の注意は、期待値や対象ページを動かして通さないことです。例外の判断が必要な項目は、最後まで人の目に残ります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。