accessibility-implementation について
accessibility-implementationは、WCAG、ARIA、キーボード操作、スクリーンリーダー対応を実装する開発者向けスキルです。ページやコンポーネントの確認に使う自動・手動テスト方法も扱います。
主な機能
- 01WCAG 2.1/2.2基準の実装
- 02ARIAのロール・状態・属性の適用
- 03キーボード操作とフォーカス管理
- 04スクリーンリーダーへの通知対応
- 05自動アクセシビリティテスト
活用シーン
- 01カスタムコンポーネントへのキーボード操作追加
- 02ダイアログのフォーカス制御と復帰
- 03ページのアクセシビリティ違反の確認
- 04フォームラベルとエラーメッセージの改善
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ laurigates/ claude-plugins --skill accessibility-implementationインストール後、Claude Code で/accessibility-implementationを入力して読み込まれたか確認します。
詳細
アクセシビリティの実装
WCAG ガイドライン、ARIA パターン、支援技術のサポートに関する技術的な実装です。
このスキルを使用するタイミング
| 次の場合はこのスキルを使用します... | 次の場合は design-tokens を使用します... |
|---|---|
| コードで WCAG 2.1/2.2 の達成基準を実装する場合 | CSS カスタムプロパティやテーマシステムを設定する場合 |
| ARIA ロール、状態、ライブリージョンを追加する場合 | テーマで使用するセマンティックな色トークンを定義する場合 |
| キーボードナビゲーション、フォーカストラップ、スキップリンクを設定する場合 | プリミティブ/セマンティック/コンポーネントのトークン階層を整理する場合 |
| axe-core、jest-axe、Playwright でコンポーネントを監査する場合 | ライト/ダークモードのトークン上書きを実装する場合 |
主な専門知識
- WCAG 準拠:コードで WCAG 2.1/2.2 の達成基準を実装します
- ARIA パターン:ロール、状態、プロパティを適切に使用します
- キーボードナビゲーション:フォーカス管理、キー操作のハンドラー、論理的なタブ順序
- スクリーンリーダー:コンテンツ構造、通知、ライブリージョン
- テスト:自動および手動のアクセシビリティテスト
WCAG クイックリファレンス
レベル A(必須)
| 基準 | 実装 |
|---|---|
| 1.1.1 非テキストコンテンツ | 画像の alt、入力欄のラベル |
| 1.3.1 情報と関係性 | セマンティック HTML、ARIA による関係性 |
| 2.1.1 キーボード | すべてのインタラクティブ要素をキーボードで操作可能にする |
| 2.4.1 ブロックスキップ | スキップリンク、ランドマーク |
| 4.1.2 名前、役割、値 | カスタムウィジェットの ARIA ラベル、ロール |
レベル AA(推奨)
| 基準 | 実装 |
|---|---|
| 1.4.3 コントラスト(最低限) | テキストは 4.5:1、大きなテキストは 3:1 |
| 1.4.11 非テキストのコントラスト | UI コンポーネントは 3:1 |
| 2.4.6 見出しとラベル | 説明的で階層的な見出し |
| 2.4.7 フォーカスの可視化 | 視認できるフォーカスインジケーター(2px 以上のアウトライン) |
ARIA パターン
ボタンとリンク
<div role="button" tabindex="0"
aria-pressed="false"
onkeydown="handleKeyDown(event)">
Toggle Feature
</div>
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<a href="/page">Go to page</a>
<button type="button">Submit form</button>
フォームコントロール
<label for="email">Email address</label>
<input id="email" type="email"
aria-describedby="email-hint email-error"
aria-invalid="true"
required>
<div id="email-hint">We'll never share your email</div>
<div id="email-error" role="alert">Please enter a valid email</div>
<fieldset>
<legend>Notification preferences</legend>
<label><input type="checkbox" name="notif" value="email"> Email</label>
<label><input type="checkbox" name="notif" value="sms"> SMS</label>
</fieldset>
<label for="country">Country</label>
<input id="country"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="country-listbox">
<ul id="country-listbox" role="listbox" hidden>
<li role="option" id="opt-us">United States</li>
<li role="option" id="opt-uk">United Kingdom</li>
</ul>
モーダルダイアログ
<div role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-desc">Are you sure you want to proceed?</p>
<button>Cancel</button>
<button>Confirm</button>
</div>
// Focus trap implementation
function trapFocus(dialog: HTMLElement) {
const focusable = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') {
closeDialog();
}
});
// Move focus to first element
first.focus();
}
タブ
<div role="tablist" aria-label="Settings tabs">
<button role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1">
General
</button>
<button role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1">
Privacy
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
General settings content
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Privacy settings content
</div>
// Tab keyboard navigation
tablist.addEventListener('keydown', (e) => {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const current = tabs.indexOf(document.activeElement as Element);
let next: number;
switch (e.key) {
case 'ArrowRight':
next = (current + 1) % tabs.length;
break;
case 'ArrowLeft':
next = (current - 1 + tabs.length) % tabs.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
(tabs[next] as HTMLElement).focus();
activateTab(tabs[next]);
});
ライブリージョン
<div role="status" aria-live="polite">
Form saved successfully
</div>
<div role="alert" aria-live="assertive">
Error: Connection lost
</div>
<div aria-live="polite" aria-atomic="true">
Loading: 45% complete
</div>
キーボードナビゲーション
標準キー割り当て
| キー | 動作 |
|---|---|
| Tab | 次のフォーカス可能な要素に移動 |
| Shift+Tab | 前のフォーカス可能な要素に移動 |
| Enter/Space | ボタンを有効化、オプションを選択 |
| Escape | モーダルを閉じる、操作をキャンセル |
| 矢印キー | コンポーネント内を移動(タブ、メニュー、リストボックス) |
| Home/End | リストの最初/最後の項目に移動 |
フォーカス管理
// Return focus after modal close
const triggerElement = document.activeElement;
openModal();
// On close:
closeModal();
triggerElement?.focus();
// Move focus to error
function showValidationErrors() {
const firstError = document.querySelector('[aria-invalid="true"]');
(firstError as HTMLElement)?.focus();
}
// Skip link
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content" tabindex="-1">...</main>
ロービング tabindex
// For composite widgets (toolbar, menu, tabs)
function setRovingTabindex(container: HTMLElement, selector: string) {
const items = container.querySelectorAll(selector);
items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
});
container.addEventListener('keydown', (e) => {
const current = Array.from(items).indexOf(document.activeElement as Element);
let next = current;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (current + 1) % items.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (current - 1 + items.length) % items.length;
}
if (next !== current) {
items[current].setAttribute('tabindex', '-1');
items[next].setAttribute('tabindex', '0');
(items[next] as HTMLElement).focus();
e.preventDefault();
}
});
}
テスト
自動テスト
# axe-core CLI
npx @axe-core/cli https://localhost:3000
# Lighthouse accessibility audit
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json
# pa11y
npx pa11y http://localhost:3000
# jest-axe for unit tests
npm install --save-dev jest-axe
// jest-axe example
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('component is accessible', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Playwright accessibility testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
手動テストのチェックリスト
キーボードナビゲーション
- すべてのインタラクティブ要素に Tab キーで移動できる
- フォーカス順序が視覚的な順序と一致している
- フォーカスインジケーターが常に見える
- キーボードトラップがない
- Escape キーでモーダル/メニューを閉じられる
スクリーンリーダーのテスト
- VoiceOver(macOS):Cmd+F5
- NVDA(Windows):無料でダウンロード可能
- テスト:リンクの移動先が読み上げられる
- テスト:フォームのラベルとエラーが読み上げられる
- テスト:動的コンテンツが読み上げられる
視覚的なテスト
- 横スクロールなしで 200% まで拡大できる
- 色のコントラストが基準を満たしている
- 情報が色だけで伝えられていない
- すべてのテーマでフォーカスインジケーターが見える
よくある修正
アクセシブルな名前がない
<button><svg>...</svg></button>
<button aria-label="Close">
<svg aria-hidden="true">...</svg>
</button>
フォームラベルがない
<input placeholder="Email">
<label for="email">Email</label>
<input id="email" type="email">
<label for="search" class="visually-hidden">Search</label>
<input id="search" type="search" placeholder="Search...">
見出し構造がない
<h1>Page Title</h1>
<h3>Section</h3>
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
フォーカスが見えない
/* Bad: Removing focus outline */
button:focus { outline: none; }
/* Good: Custom focus indicator */
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
色のコントラスト
/* Bad: Low contrast */
.text { color: #999; background: #fff; } /* 2.85:1 ratio */
/* Good: Sufficient contrast */
.text { color: #595959; background: #fff; } /* 4.56:1 ratio */
CSS ユーティリティ
/* Visually hidden but accessible */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip link */
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
ベストプラクティス
まずセマンティック HTML を使用する
ARIA よりもネイティブの HTML 要素を優先してください。<div role="button"> よりも <button> の方が適切です。
デフォルトの動作を上書きしない
ネイティブ要素にはアクセシビリティ機能が組み込まれています。JavaScript でその機能を損なわないでください。
実際のユーザーでテストする
自動ツールで検出できる問題は約 30% です。支援技術を使った手動テストが不可欠です。
複数の方法を用意する
すべての操作で、キーボード、マウス、タッチの代替手段を用意してください。
References
- WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
- axe-core Rules: https://dequeuniversity.com/rules/axe/
- A11y Project Checklist: https://www.a11yproject.com/checklist/
コメント
Markdown 対応