accessibility-a11y について
スクリーンリーダーやキーボードなどの支援技術を使う人に配慮した、Web開発者向けのアクセシビリティガイドです。WCAG 2.2、ARIA、フォーカス管理、色のコントラスト、自動テストを扱います。
主な機能
- 01セマンティックHTMLとARIAの使用
- 02キーボード操作とフォーカス管理
- 03アクセシブルなフォームとエラーメッセージ
- 04動的コンテンツ向けライブリージョン
- 05WCAGのコントラスト基準
- 06自動・手動のアクセシビリティテスト
活用シーン
- 01アクセシブルなUIコンポーネントの構築
- 02既存画面の適合上の問題確認
- 03アクセシビリティ監査で見つかった問題の修正
- 04CI/CDへの自動テスト導入
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill accessibility-a11yインストール後、Claude Code で/accessibility-a11yを入力して読み込まれたか確認します。
詳細
アクセシビリティ(a11y)
概要
このスキルでは、スクリーンリーダー、キーボードのみでの操作、スイッチデバイス、その他の支援技術を利用する人を含め、誰もが使えるアクセシブルなウェブアプリケーションの構築を扱います。WCAG 2.2 の AA および AAA レベルへの準拠、適切な ARIA の使用、フォーカス管理、色のコントラスト、モーション低減への対応、自動テストの統合について説明します。
新しい UI コンポーネントの構築、既存のインターフェースのアクセシビリティ準拠状況の確認、a11y 監査で見つかった問題の修正、CI/CD パイプラインへの自動アクセシビリティテストの統合を行う際に、このスキルを使用してください。
基本原則
- まずセマンティック HTML を使う - ネイティブの HTML 要素(
<button>、<nav>、<dialog>)を使えば、特別な対応をしなくてもアクセシビリティを確保できます。ARIA はセマンティクスだけでは不十分な場合の修復手段であり、適切な HTML の代替ではありません。 - キーボード操作を基本とする - キーボードだけで操作できないなら、それは機能していません。すべてのインタラクティブな要素にフォーカスでき、操作可能で、フォーカスが視覚的に示される必要があります。
- 実際の支援技術でテストする - 自動ツールで検出できるアクセシビリティの問題は約 30% です。残りは、スクリーンリーダー(VoiceOver、NVDA)やキーボードのみの操作による手動テストが必要です。
- プログレッシブエンハンスメントを採用する - まずアクセシブルなバージョンを構築し、その後で視覚的な拡張を重ねます。視覚のあるユーザーに見えるコンテンツを、支援技術から隠してはいけません。
- 色だけで情報を伝えない - 色は意味を補強できますが、唯一の指標にしてはいけません。色とあわせて、アイコン、テキストラベル、パターンを使います。
主なパターン
パターン 1:アクセシブルなモーダルダイアログ
使用する場面: メインコンテンツに戻る前に、ユーザーの操作が必要なオーバーレイ全般。
実装:
import { useRef, useEffect, useCallback } from "react";
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
// Store the element that had focus before opening
previousFocusRef.current = document.activeElement as HTMLElement;
dialog.showModal();
} else {
dialog.close();
// Restore focus to the triggering element
previousFocusRef.current?.focus();
}
}, [isOpen]);
// Handle Escape key (native dialog handles this, but we need cleanup)
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
// Handle backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === dialogRef.current) {
onClose();
}
},
[onClose]
);
if (!isOpen) return null;
return (
<dialog
ref={dialogRef}
onClose={handleClose}
onClick={handleBackdropClick}
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
className="dialog"
>
<div className="dialog-content" role="document">
<header className="dialog-header">
<h2 id="dialog-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="dialog-close"
>
<span aria-hidden="true">×</span>
</button>
</header>
<div id="dialog-description">{children}</div>
</div>
</dialog>
);
}
/* Focus trap is handled natively by <dialog> showModal() */
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
dialog .dialog-close:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
理由: ネイティブの <dialog> 要素で showModal() を使用すると、フォーカストラップ、Escape キーへの対応、適切な role="dialog" セマンティクスが自動的に提供されます。独自実装のモーダルでは、フォーカストラップの不具合がほぼ必ず発生します。ネイティブ要素を使えば、正しい動作をそのまま利用できます。
パターン 2:エラー処理に対応したアクセシブルなフォーム
使用する場面: ユーザー入力を受け取り、検証するフォーム全般。
実装:
interface FormFieldProps {
id: string;
label: string;
type?: string;
required?: boolean;
error?: string;
description?: string;
value: string;
onChange: (value: string) => void;
}
function FormField({
id,
label,
type = "text",
required = false,
error,
description,
value,
onChange,
}: FormFieldProps) {
const descriptionId = description ? `${id}-description` : undefined;
const errorId = error ? `${id}-error` : undefined;
// Build aria-describedby from available descriptions
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="form-field">
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (required)</span>}
</label>
{description && (
<p id={descriptionId} className="field-description">
{description}
</p>
)}
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
aria-invalid={error ? "true" : undefined}
aria-describedby={describedBy}
aria-required={required}
/>
{error && (
<p id={errorId} className="field-error" role="alert">
<span aria-hidden="true">!</span> {error}
</p>
)}
</div>
);
}
// Form-level error summary for screen readers
function ErrorSummary({ errors }: { errors: Record<string, string> }) {
const errorEntries = Object.entries(errors);
if (errorEntries.length === 0) return null;
return (
<div role="alert" aria-labelledby="error-summary-title" className="error-summary">
<h3 id="error-summary-title">
{errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found
</h3>
<ul>
{errorEntries.map(([field, message]) => (
<li key={field}>
<a href={`#${field}`}>{message}</a>
</li>
))}
</ul>
</div>
);
}
/* Screen-reader only class */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.field-error {
color: var(--color-error);
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Never rely on color alone for errors - include icon */
.field-error::before {
content: "";
/* Error icon via background-image */
}
input[aria-invalid="true"] {
border-color: var(--color-error);
/* Also use a thicker border or icon, not just color */
border-width: 2px;
}
理由: フォームは、アクセシビリティ上の問題が最もよく発生する箇所です。このパターンでは、すべてのフィールドにプログラムから認識できるラベルが付き、エラーが role="alert" によって通知され、エラーメッセージが aria-describedby によって入力欄と関連付けられます。また、エラーの概要から、キーボードユーザーが問題のあるフィールドへ直接移動できます。
パターン 3:カスタムウィジェットのキーボード操作
使用する場面: ネイティブの HTML 要素に当てはまらない、タブ、メニュー、リストボックス、コンボボックスなどのカスタムインタラクティブコンポーネントを構築する場合。
実装:
// Accessible tabs following WAI-ARIA Authoring Practices
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let newIndex = index;
switch (e.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return; // Don't prevent default for other keys
}
e.preventDefault();
setActiveIndex(newIndex);
// Move focus to the newly active tab
const tabElement = document.getElementById(`tab-${tabs[newIndex].id}`);
tabElement?.focus();
};
return (
<div>
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
aria-selected={index === activeIndex}
aria-controls={`panel-${tab.id}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
id={`panel-${tab.id}`}
role="tabpanel"
aria-labelledby={`tab-${tab.id}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}
理由: カスタムウィジェットでは、WAI-ARIA Authoring Practices で定められた想定上のキーボード操作パターンを実装する必要があります。タブでは、Tab キーではなく矢印キーでタブ間を移動します。また、非アクティブなタブには tabIndex={-1} を設定し、アクティブなタブだけが Tab キーによる移動順に含まれるようにします。これにより、スクリーンリーダーのユーザーが期待する操作モデルに沿った動作になります。
パターン 4:動的コンテンツのライブリージョン
使用する場面: ページの再読み込みなしでコンテンツが更新され、スクリーンリーダーのユーザーへの通知が必要な場合(通知、検索結果、読み込み状態など)。
実装:
// Toast notification system with live regions
function ToastContainer({ toasts }: { toasts: Toast[] }) {
return (
<div
aria-live="polite"
aria-atomic="false"
aria-relevant="additions"
className="toast-container"
>
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={`toast toast-${toast.type}`}
>
<span className="toast-icon" aria-hidden="true">
{toast.type === "success" ? "check" : "warning"}
</span>
<span>{toast.message}</span>
<button
onClick={() => dismissToast(toast.id)}
aria-label={`Dismiss: ${toast.message}`}
>
<span aria-hidden="true">×</span>
</button>
</div>
))}
</div>
);
}
// For urgent errors, use role="alert" (assertive)
function CriticalError({ message }: { message: string }) {
return (
<div role="alert" className="critical-error">
{message}
</div>
);
}
// Search results count announcement
function SearchResults({ query, count }: { query: string; count: number }) {
return (
<>
<div aria-live="polite" className="sr-only">
{count} results found for "{query}"
</div>
{/* Visual results list */}
</>
);
}
理由: スクリーンリーダーは、視覚的な変更を検出するために DOM を監視しません。ライブリージョンを使うと、新しいコンテンツを読み上げるよう支援技術に明示的に伝えられます。緊急性のない更新(検索結果、トースト通知)には aria-live="polite" を、緊急のメッセージ(エラー、セッションの有効期限切れ)には role="alert" を使用します。
パターン 5:自動アクセシビリティテスト
使用する場面: すべてのプロジェクトで、CI/CD と開発ワークフローに組み込みます。
実装:
// Jest + axe-core for component testing
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
describe("LoginForm", () => {
it("should have no accessibility violations", async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("should have no violations in error state", async () => {
const { container } = render(<LoginForm />);
// Trigger validation errors
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// Playwright + axe for E2E accessibility testing
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("accessibility", () => {
test("home page passes axe audit", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
expect(results.violations).toEqual([]);
});
test("modal dialog passes axe audit when open", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "Create project" }).click();
const results = await new AxeBuilder({ page })
.include(".dialog")
.analyze();
expect(results.violations).toEqual([]);
});
});
# GitHub Actions CI integration
name: Accessibility Audit
on: [pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- name: Run axe accessibility tests
run: npx playwright test --grep "accessibility"
- name: Run pa11y on built pages
run: |
npx serve -s build -l 3000 &
sleep 3
npx pa11y-ci --config .pa11yci.json
理由: 自動テストを行うと、構造上の問題(ラベルの欠落、無効な ARIA、コントラスト不足)を一貫して早期に検出できます。CI で問題の 30%を自動検出できるほうが、0%のままリリース後に問題を発見するよりも優れています。網羅性を高めるには、手動テストと組み合わせてください。
カラーコントラスト早見表
| テキストサイズ | WCAG AA | WCAG AAA |
|---|---|---|
| 通常のテキスト(< 18px / 14px 太字) | 4.5:1 | 7:1 |
| 大きなテキスト(>= 18px / 14px 太字) | 3:1 | 4.5:1 |
| UI コンポーネントとグラフィックオブジェクト | 3:1 | 3:1 |
| 装飾要素/無効化された要素 | 要件なし | 要件なし |
ARIA 早見表
| 必要なこと | これを使う | これは使わない |
|---|---|---|
| 入力欄のラベル | <label for="x"> または aria-labelledby | placeholder のみをラベルとして使う |
| 入力要件の説明 | ヒントテキストを参照する aria-describedby | title 属性 |
| 装飾的なコンテンツを非表示にする | aria-hidden="true" | display: none(すべてのユーザーから非表示になる) |
| 必須項目を示す | aria-required="true" + required | 視覚的なアスタリスクのみ |
| エラーを通知する | role="alert" または aria-live="assertive" | テキストの色だけを変更する |
| 展開可能なセクション | aria-expanded="true/false" | カスタムの data-open 属性 |
アンチパターン
| アンチパターン | 問題点 | よりよい方法 |
|---|---|---|
<button> の代わりに <div onclick> を使う | キーボードで操作できず、ロールもフォーカスもない | クリック可能な要素には <button> を使う |
| ネイティブ HTML で実現できるところに ARIA を使う | 複雑になり、バグが増える | <div role="navigation"> ではなく <nav> を使う |
tabindex="5"(正の値) | 自然なタブ順序が崩れる | tabindex="0" または tabindex="-1" のみを使う |
表示テキストと重複する aria-label | スクリーンリーダーで二度読み上げられる | aria-label は非表示のラベルにのみ使う |
フォーカスのアウトラインを非表示にする(outline: none) | キーボードユーザーが現在位置を見失う | 削除するのではなく、:focus-visible をスタイル設定する |
ページ読み込み時の autofocus | スクリーンリーダーのユーザーを戸惑わせる | ユーザーが自然に移動できるようにする |
| テキストを画像にする | 拡大できず、翻訳もできない | CSS でスタイルを設定した実際のテキストを使う |
チェックリスト
- すべてのインタラクティブ要素をキーボードで操作できる(Tab、Enter、Space、Escape、矢印キー)
- フォーカス可能なすべての要素でフォーカスインジケーターが表示される(
:focus-visible) - すべての画像に説明的な
altテキストがある(装飾画像の場合はalt="") - フォーム入力欄に対応する
<label>要素がある - エラーメッセージがプログラムから入力欄に関連付けられている(
aria-describedby) - カラーコントラストが WCAG AA を満たしている(通常のテキストは 4.5:1、大きなテキストは 3:1)
- 色だけで伝えている情報がない
- アニメーションで
prefers-reduced-motionメディアクエリーに対応している - ページの見出し階層が正しい(h1 > h2 > h3、階層を飛ばさない)
- 言語が宣言されている(
<html lang="en">) - CI で axe-core のチェックを行う(ユーザーが自動 a11y テストを求めた場合のみ)
- VoiceOver(macOS)または NVDA(Windows)を使った手動テストを実施している
関連リソース
- スキル:
performance-engineering(パフォーマンスと a11y の交差領域)、growth-engineering(アクセシブルなオンボーディング) - ルール:
docs/reference/checklists/ui-visual-changes.md(視覚的な変更に関するアクセシビリティチェック) - ルール:
docs/reference/stacks/react-typescript.md(React コンポーネントのパターン)