a11y について
Web アプリのアクセシビリティ問題を検出・修正するスキルで、開発者向けです。axe-core のスキャン、キーボード操作テスト、コード検査を組み合わせ、画面ごとに修正後の再テストを行います。
主な機能
- 01デスクトップとモバイルで axe スキャン
- 02キーボード操作とフォーカス表示をテスト
- 03ソースコードの構造上の問題を確認
- 04影響度に応じて問題を修正
- 05再テストして未解決項目を報告
活用シーン
- 01アプリ各画面のアクセシビリティ監査
- 02キーボード操作とフォーカスの問題発見
- 03モバイルのタップ領域とコントラスト確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dawitlabs/ ui-skills --skill a11yインストール後、Claude Code で/a11yを入力して読み込まれたか確認します。
同梱ファイルWCAG.md
詳細
/a11y — アクセシビリティ監査の全項目
アプリ全体にわたるすべてのアクセシビリティ違反を検出して修正します。重大な違反から軽微な違反まで、画面ごとにプログラムによる検査(axe-core)と目視確認(キーボード操作のスクリーンショット)を行います。
WCAG のクイックリファレンスと修正パターンの全項目:WCAG.md
フェーズ 0 — 環境設定
0a. 開発サーバーを確認/起動する
curl -s --max-time 2 -o /dev/null -w "%{http_code}" http://localhost:3000
200 でない場合は、開発コマンドを探して実行し、完了するまで待ちます。
until curl -s --max-time 2 http://localhost:3000 > /dev/null 2>&1; do sleep 1; done && echo "ready"
0b. ソースルートを探す
find . -type d -name "src" \
-not -path "*/node_modules/*" \
-not -path "*/.next/*" \
-not -path "*/dist/*" \
-not -path "*/.turbo/*" \
| head -1
src/ がない場合:find . -type d -name "app" -not -path "*/node_modules/*" -not -path "*/.next/*" | head -1
以降、結果を <SRC> として使用します。
0c. axe-core を確認する
find . -path "*/axe-core/package.json" -not -path "*/node_modules/.cache/*" 2>/dev/null | head -1
見つからない場合は、開発依存関係としてのみインストールします。
pnpm add -D axe-core 2>/dev/null || npm install -D axe-core 2>/dev/null || yarn add -D axe-core
後でインジェクションする axe バンドルのパスを解決します。
find . -path "*/axe-core/axe.min.js" -not -path "*/node_modules/.cache/*" 2>/dev/null | head -1
これを <AXE_PATH> として保存します。
0d. スキャンスクリプトを書く
プロジェクトのルートに書き込みます(node_modules が必要です)。フェーズ 4 で削除します。
_a11y_scan.mjs:
import { chromium } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'fs';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const get = (f) => { const i = process.argv.indexOf(f); return i !== -1 ? process.argv[i+1] : null; };
const url = get('--url');
const name = get('--name') ?? 'screen';
const width = parseInt(get('--width') ?? '1280');
const height = parseInt(get('--height') ?? '900');
const auth = get('--auth');
const screenshot = get('--screenshot') ?? `/tmp/a11y-${name}.png`;
const report = get('--report') ?? `/tmp/a11y-${name}.json`;
if (!url) { console.error('--url required'); process.exit(1); }
mkdirSync('/tmp/a11y', { recursive: true });
const axePath = (() => {
try { return require.resolve('axe-core'); }
catch { console.error('axe-core not found — run: pnpm add -D axe-core'); process.exit(1); }
})();
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width, height },
...(auth ? { storageState: auth } : {}),
});
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
} catch {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
}
// Inject axe-core from local node_modules
await page.addScriptTag({ path: axePath });
// Run axe scan
const results = await page.evaluate(async () => {
const r = await window.axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
});
return {
violations: r.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
help: v.help,
helpUrl: v.helpUrl,
nodes: v.nodes.map(n => ({ html: n.html, target: n.target, failureSummary: n.failureSummary })),
})),
incomplete: r.incomplete.length,
passes: r.passes.length,
};
});
await page.screenshot({ path: screenshot, fullPage: true });
await browser.close();
writeFileSync(report, JSON.stringify(results, null, 2));
const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
for (const v of results.violations) counts[v.impact] = (counts[v.impact] ?? 0) + 1;
console.log(`saved: ${screenshot}`);
console.log(`report: ${report}`);
console.log(`violations: critical=${counts.critical} serious=${counts.serious} moderate=${counts.moderate} minor=${counts.minor}`);
console.log(`passes: ${results.passes} | incomplete: ${results.incomplete}`);
使用方法:
node _a11y_scan.mjs --url http://localhost:3000 --name home
node _a11y_scan.mjs --url http://localhost:3000/dashboard --name dashboard --auth /tmp/uiux-auth-state.json
node _a11y_scan.mjs --url http://localhost:3000/settings --name settings --width 375 --height 812
スキャンのたびに、JSON レポートとスクリーンショットを確認してください。
0e. キーボードナビゲーションスクリプトを書く
プロジェクトのルートに書き込みます。フェーズ 4 で削除します。
_a11y_keyboard.mjs:
import { chromium } from '@playwright/test';
import { mkdirSync } from 'fs';
const get = (f) => { const i = process.argv.indexOf(f); return i !== -1 ? process.argv[i+1] : null; };
const url = get('--url');
const name = get('--name') ?? 'screen';
const width = parseInt(get('--width') ?? '1280');
const height = parseInt(get('--height') ?? '900');
const auth = get('--auth');
const maxTabs = parseInt(get('--max-tabs') ?? '30');
if (!url) { console.error('--url required'); process.exit(1); }
mkdirSync('/tmp/a11y', { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width, height },
...(auth ? { storageState: auth } : {}),
});
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
} catch {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
}
const issues = [];
// Check for skip link as first tab stop
await page.keyboard.press('Tab');
const skipLink = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
return { tag: el.tagName, text: el.textContent?.trim(), href: el.getAttribute('href') };
});
const hasSkipLink = skipLink?.text?.toLowerCase().includes('skip') && skipLink?.href?.startsWith('#');
if (!hasSkipLink) issues.push({ type: 'missing-skip-link', detail: 'First Tab stop is not a "Skip to main content" link' });
// Tab through interactive elements
const focusLog = [];
for (let i = 0; i < maxTabs; i++) {
await page.keyboard.press('Tab');
const focusInfo = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
// Capture focused styles
const focusedBorder = style.borderColor;
const focusedBackground = style.backgroundColor;
// Briefly blur to capture unfocused styles, then refocus
el.blur();
const blurStyle = getComputedStyle(el);
const blurBorder = blurStyle.borderColor;
const blurBackground = blurStyle.backgroundColor;
el.focus();
return {
tag: el.tagName,
role: el.getAttribute('role'),
ariaLabel: el.getAttribute('aria-label'),
text: el.textContent?.trim().slice(0, 60),
outlineWidth: style.outlineWidth,
outlineStyle: style.outlineStyle,
boxShadow: style.boxShadow,
hasBorderChange: focusedBorder !== blurBorder,
hasBackgroundChange:focusedBackground !== blurBackground,
width: Math.round(rect.width),
height: Math.round(rect.height),
};
});
if (!focusInfo) break;
// Detect focus visibility via outline, box-shadow, or meaningful border/background change.
// Compare against a snapshot of the element's non-focused style by briefly blurring and refocusing.
const hasVisibleFocus = (
(focusInfo.outlineStyle !== 'none' && focusInfo.outlineWidth !== '0px')
|| (focusInfo.boxShadow && focusInfo.boxShadow !== 'none')
|| focusInfo.hasBorderChange
|| focusInfo.hasBackgroundChange
);
const hasName = !!(focusInfo.ariaLabel || focusInfo.text);
const isTiny = focusInfo.width < 44 || focusInfo.height < 44;
if (!hasVisibleFocus) issues.push({ type: 'no-focus-visible', element: focusInfo.tag, text: focusInfo.text });
if (!hasName) issues.push({ type: 'missing-accessible-name', element: focusInfo.tag });
if (isTiny) issues.push({ type: 'small-tap-target', element: focusInfo.tag, text: focusInfo.text, size: `${focusInfo.width}×${focusInfo.height}` });
focusLog.push({ ...focusInfo, hasVisibleFocus, hasName });
}
// Screenshot after keyboard traversal (last focused state)
await page.screenshot({ path: `/tmp/a11y/${name}-keyboard.png`, fullPage: false });
await browser.close();
console.log(`keyboard screenshot: /tmp/a11y/${name}-keyboard.png`);
console.log(`tab stops visited: ${focusLog.length}`);
if (issues.length) {
console.log('issues found:');
issues.forEach(i => console.log(' -', JSON.stringify(i)));
} else {
console.log('no keyboard issues found');
}
使用方法:
node _a11y_keyboard.mjs --url http://localhost:3000 --name home
node _a11y_keyboard.mjs --url http://localhost:3000/dashboard --name dashboard --auth /tmp/uiux-auth-state.json --max-tabs 50
0f. 認証で保護された画面に対応する
ls /tmp/uiux-auth-state.json /tmp/animate-auth-state.json /tmp/copy-auth-state.json 2>/dev/null | head -1
保護されたルートへのすべてのスクリプト呼び出しに --auth <path> を渡します。
見つからず、保護された画面がある場合は、_a11y_auth.mjs をプロジェクトのルートに書き込みます。
import { chromium } from '@playwright/test';
const { TEST_EMAIL, TEST_PASSWORD, LOGIN_URL, SUCCESS_URL } = process.env;
if (!TEST_EMAIL || !TEST_PASSWORD) { console.error('Set TEST_EMAIL and TEST_PASSWORD'); process.exit(1); }
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(LOGIN_URL ?? 'http://localhost:3000/sign-in');
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(SUCCESS_URL ?? '**/dashboard**', { timeout: 15000 });
await page.context().storageState({ path: '/tmp/a11y-auth-state.json' });
await browser.close();
console.log('saved → /tmp/a11y-auth-state.json');
フェーズ 1 — アクセシビリティ監査
1a. すべての画面を探す
App Router のすべての page.tsx、Pages Router のすべてのページ、モーダル、ドロワー、認証フロー、設定、オンボーディングを対象にします。それぞれをルートとともに一覧にします。
1b. すべての画面で axe スキャンを実行する
各画面について、デスクトップとモバイルの両方のビューポートで実行します。タップ対象の失敗はモバイルでのみ発生します。
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-desktop
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-mobile --width 375 --height 812
両方の JSON レポートと両方のスクリーンショットを確認します。違反を影響度別に分類します。
- 重大 —
impact: "critical"— 支援技術でコンテンツにアクセスできない - 深刻 —
impact: "serious"— 大きな障壁。通常は WCAG AA の失敗 - 中程度 —
impact: "moderate"— 利用体験を損なう - 軽微 —
impact: "minor"— ベストプラクティスへの違反
1c. すべての画面でキーボードナビゲーションテストを実行する
各画面で _a11y_keyboard.mjs を実行します。問題を標準出力で確認し、キーボード操作のスクリーンショットも確認します。
node _a11y_keyboard.mjs --url http://localhost:3000<ROUTE> --name <name>
出力を見て、次の点を手動で確認します。
- 最初の Tab 停止位置はスキップリンクですか?
- すべてのインタラクティブ要素に、目に見えるフォーカスリングがありますか?
- すべてのボタンとリンクに、アクセシブルな名前が付いていますか?
- すべてのタップ対象は 44×44px 以上ですか?
1d. 構造上の問題をコードレベルでスキャンする
SRC=<your source root>
# Images without alt
grep -rn "<img" "$SRC" --include="*.tsx" --include="*.jsx" | grep -v "alt="
# Buttons with no accessible name
grep -rn "<button" "$SRC" --include="*.tsx" | grep -v "aria-label\|aria-labelledby"
# onClick on non-interactive elements
grep -rn "onClick" "$SRC" --include="*.tsx" | grep -v "button\|a\|input\|select\|textarea\|role="
# Missing form labels
grep -rn "<input" "$SRC" --include="*.tsx" | grep -v "aria-label\|aria-labelledby\|id=" | grep -v "type=\"hidden\""
# Heading hierarchy — look for h3 without an h2 parent context
grep -rn "<h[1-6]" "$SRC" --include="*.tsx"
# lang attribute on html element
grep -rn "<html" "$SRC" --include="*.tsx" --include="*.html"
# color-only state signals (errors shown only with a red class, no icon/text)
grep -rn "text-red\|text-error\|border-red" "$SRC" --include="*.tsx" | head -20
# Dynamic content missing aria-live
# Find containers that update dynamically but have no live region announcement
grep -rn "useState\|useEffect\|isLoading\|isError\|toast\|notification\|alert" "$SRC" --include="*.tsx" -l | \
xargs grep -L "aria-live\|aria-atomic\|role=\"alert\"\|role=\"status\""
aria-live が必要な動的コンテンツ:
- トースト/通知メッセージ
- フォームの入力検証エラー(送信後)
- 検索結果の件数(「3 件の結果が見つかりました」)
- 読み込み完了メッセージ
- リアルタイムのデータ更新
ページを再読み込みせずにコンテンツを更新するコンポーネントに aria-live がない場合、スクリーンリーダーはその更新を認識できません。手動確認の対象として指摘します。
1e. prefers-reduced-motion を確認する
grep -rn "prefers-reduced-motion" "$SRC" --include="*.css" --include="*.tsx" --include="*.ts"
まったく設定されていない場合は重大な問題として指摘します。すべてのアニメーションを抑制できなければなりません。
続行する前に、監査結果全体を画面ごとに分類して提示します。
フェーズ 2 — 優先順位付け
各画面を採点します。
| 要素 | 点数 |
|---|---|
| 重大な違反がある(コンテンツにアクセスできない) | +4 |
| 深刻な違反がある(WCAG AA の失敗) | +3 |
| 入口となる画面/アクセス数が多い画面(ランディング、認証、ダッシュボード) | +3 |
| 収益に直結する画面(チェックアウト、料金、サインアップ) | +3 |
| キーボードナビゲーションが機能しない、またはトラップされる | +3 |
| 中程度の違反がある | +2 |
| 軽微な違反がある | +1 |
| 管理者/パワーユーザーのみが利用する | −1 |
点数の高い順に並べます。フェーズ 3 を開始する前に、ユーザーに確認を求めます。
フェーズ 3 — 再帰的な修正ループ
優先順位の高い画面から順に対応します。画面ごとの反復は最大 3 回です。
3a. 違反レポートを読む
フェーズ 1 で作成した、この画面の JSON を読みます。影響度別に分類します。重大 → 深刻 → 中程度 → 軽微の順です。axe のスクリーンショットとキーボード操作のスクリーンショットをもう一度確認します。
3b. 分析
違反項目ごとに、ソースファイルを読んでコンテキストを把握します。適切な修正パターンについてはWCAG.mdを参照します。次のように分類します。
- 重大 — スクリーンリーダーでコンテンツにアクセスできない、キーボードトラップがある、lang 属性がない
- 大 — 色のコントラストが不十分、フォームラベルがない、画像に alt がない、ボタンに名前がない、非セマンティックなインタラクティブ要素がある
- 軽微 — ARIA の誤用、見出しのスキップ、スキップリンクがない、タップターゲットが小さい、prefers-reduced-motion がない
3c. 修正 — まず構造、次に視覚面
優先順位:
- セマンティックな構造(lang、見出し、ランドマーク、リスト)
- インタラクティブ要素(ボタンのロール、キーボード操作、div に onClick を設定しない)
- 名前とラベル(aria-label、alt テキスト、フォームラベル)
- ARIA の正確性(ロール、状態、プロパティ)
- コントラスト(ui-design-principles の HSB 調整を使用)
- フォーカスの視認性(
focus-visible:outlineまたはfocus-visible:ringを追加) - モーション(設定されていない場合は
prefers-reduced-motionを追加) - タップターゲット(幅や高さを直接変更せず、パディングを増やす)
修正は自動的に適用します。許可は不要です。
Semantic HTML:
<div onClick> → <button>
<div role="button"> → <button> (unless ARIA role is truly needed)
<img> missing alt → alt="" for decorative, descriptive alt for informative
heading skip (h1→h3) → add missing h2
Labels:
<input> no label → add aria-label or associate <label htmlFor>
<button> no text → add aria-label describing the action
icon-only button → add aria-label, add <span className="sr-only">
ARIA:
aria-label on non-interactive div → move to the interactive child
role="button" on <a> → remove role, keep <a> with href
aria-hidden on focusable element → remove aria-hidden or tabIndex={-1}
Contrast:
Use HSB adjustments: darker = increase S + decrease B
Prefer token changes over component-level overrides
Focus:
Add to global CSS or Tailwind config:
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand
Never suppress with: outline: none (unless replacing with custom focus style)
Skip link:
Add as first child of <body>:
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-black">
Skip to main content
</a>
Add id="main-content" to the <main> element.
aria-live regions:
// Non-urgent announcements (search results, save confirmations)
<div aria-live="polite" aria-atomic="true" className="sr-only">{statusMessage ?? ''}</div>
// Urgent interruptions (form errors, session expiry)
<p role="alert">{errorMessage}</p>
// CRITICAL: container must be in the DOM BEFORE content changes.
// Wrong: {message && <div aria-live="polite">{message}</div>}
// Right: <div aria-live="polite" className="sr-only">{message ?? ''}</div>
prefers-reduced-motion:
In globals.css — verify this rule exists, add if missing:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
3d. 修正後に再スキャン
sleep 3 # hot reload
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-after-desktop
node _a11y_scan.mjs --url http://localhost:3000<ROUTE> --name <name>-after-mobile --width 375 --height 812
node _a11y_keyboard.mjs --url http://localhost:3000<ROUTE> --name <name>-after
新しい JSON レポートを読みます。修正前後の違反件数を比較します。スクリーンショットを確認します。
次の項目を確認します。
- 重大および深刻な違反が解消されているか?
- すべてのインタラクティブ要素でキーボードフォーカスが見えるか?
- スキップリンクがあり、機能しているか?
- すべてのボタンと入力欄にアクセシブルな名前があるか?
- コントラスト比が 4.5:1(本文)および 3:1(大きなテキスト、UI コンポーネント)に達しているか?
重大または深刻な違反が残っている場合は、3c に戻ります(反復回数を増やし、最大 3 回)。
3e. アクセシビリティチェックリスト
次の画面に進む前に、以下を確認します。
- axe レポートに重大な違反がゼロ
- axe レポートに深刻な違反がゼロ
-
<html>要素にlang属性がある - ページごとに
<main>ランドマークが 1 つ、<h1>が 1 つある - 見出し階層が順序どおり(h1→h3 のようなスキップがない)
- スキップリンクが最初にフォーカス可能な要素で、
#main-contentをターゲットにしている - すべてのインタラクティブ要素に、論理的な順序で Tab キーから到達できる
- フォーカスされたすべての要素に、視認できるフォーカスリングがある(ブラウザーのデフォルトを削除していない)
- キーボードトラップがない(モーダルから Esc キーで抜ける場合も含め、すべての要素から Tab キーで移動できる)
- すべての
<img>に alt がある — 装飾用は空の""、情報提供用は内容を説明するテキスト - すべての
<input>に、関連付けられた<label>またはaria-labelがある - アイコンのみのボタンすべてに
aria-labelがある - 非インタラクティブ要素(
<div>、<span>、<p>)にonClickがない - 色だけを唯一の手がかりにしていない(エラーは色だけでなく、アイコンとテキストでも示す)
- 本文テキストのコントラスト比が ≥4.5:1、大きなテキストが ≥3:1、UI コンポーネントが ≥3:1
- グローバル CSS に
prefers-reduced-motionルールがある - タッチターゲットが ≥44×44px(キーボードスクリプトの出力で確認済み)
- モーダル/ドロワーは開いている間フォーカスをトラップし、閉じるとトリガーにフォーカスを戻す
- 動的コンテンツ(トースト、エラー、検索件数、読み込み完了)は、
aria-live="polite"またはrole="alert"で囲まれている(緊急メッセージの場合) -
aria-liveコンテナーは、コンテンツが変化する前から DOM に存在する(コンテンツと一緒にマウント・アンマウントしない)
重大/深刻な不合格があれば、3c に戻ります。3 回目の反復後は、問題にフラグを付けて先に進みます。
フェーズ 4 — クリーンアップとレポート
一時スクリプトを削除します。
rm -f _a11y_scan.mjs _a11y_keyboard.mjs _a11y_auth.mjs
各画面の後に:
✓ Screen name (rank N, iteration K) — A critical + B serious + C moderate + D minor fixed
最終サマリーテーブル:
| 順位 | 画面 | ルート | 重大 | 深刻 | 中程度 | 軽微 | 反復回数 | 状態 |
|---|---|---|---|---|---|---|---|---|
| 1 | ... | ... | 0 | 0 | ... | ... | ... | ✓ / ⚠ |
解決できなかった具体的な問題とともに、フラグを付けたすべての違反項目を末尾に列挙します。
ルール
- Axe-coreは最低ラインであり、ゴールではありません。 実際のアクセシビリティ上の問題の約30~40%を検出できます。キーボードテストとコードスキャンで残りを検出します。
- 件数だけでなく、JSONを読みましょう。 修正する前に、それぞれの違反が何を意味するのかを理解してください。
- ARIAよりセマンティックHTMLを優先します。 本物の
<button>は、どんな場合でも<div role="button">より適切です。 - 置き換えずにフォーカススタイルを削除してはいけません。
outline: noneは、カスタムのフォーカススタイルに置き換えない限り違反です。 - 色だけで状態を伝えてはいけません。 色だけで示される状態変化には、アイコン、テキスト、パターンのいずれかも必ず使用してください。
- 見た目の問題より先に、構造上の問題を修正します。 ランドマーク、見出し、キーボード操作への対応を先に行い、コントラストは最後に対応します。
- 画面ごとの反復は最大3回です。 残った問題を記録して、次に進みます。
- ロジックや機能は絶対に変更しないでください。 変更できるのは、アクセシビリティ属性、セマンティック要素、スタイルのみです。
- 375pxでもテストしてください。 デスクトップでは問題ないタップ対象も、モバイルでは不適合になることがあります。
- モーダルのフォーカストラップは必須です。 モーダルが開いているときに、背景へフォーカスが移動できる状態は重大な不具合です。
WCAG 2.1 AAのクイックリファレンスと修正パターンの全容については、WCAG.mdをご覧ください。