accessibility-audit について
accessibility-auditは、アプリのアクセシビリティ監査を行う開発者やレビュアー向けのスキルです。段階的な手順でセマンティックHTML、キーボード操作、スクリーンリーダー、コントラスト、フォームなどを確認し、検証スクリプトとパターン資料も備えています。
主な機能
- 01WCAG 2.1 AA監査の段階的な手順
- 02セマンティックHTMLとARIAの確認
- 03キーボード操作とフォーカスの確認
- 04スクリーンリーダーと色コントラストの確認
- 05フォーム、エラー、メディアの監査
- 06検証スクリプトとパターン資料
活用シーン
- 01リリース前のアクセシビリティ確認
- 02UI改修後の操作性とフォーカスの再確認
- 03アクセシビリティに関する苦情や適合性の調査
- 04チーム内の監査手順の整備
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mgd34msu/ goodvibes-plugin --skill accessibility-auditインストール後、Claude Code で/accessibility-auditを入力して読み込まれたか確認します。
同梱ファイルreferences/accessibility-patterns.mdscripts/validate-accessibility-audit.sh
詳細
リソース
scripts/
validate-accessibility-audit.sh
references/
accessibility-patterns.md
アクセシビリティ監査
このスキルでは、WCAG 2.1 AA 準拠を確保するための包括的なアクセシビリティ監査の実施方法を説明します。アプリケーションのインクルーシブデザインを検証するとき、アクセシビリティレビューに備えるとき、またはアクセシビリティの問題を修正するときに使用してください。
このスキルを使用する場面
- リリース前のアクセシビリティ準拠レビューを実施するとき
- アクセシビリティに関する苦情や法的要件に対応するとき
- 既存のアプリケーションの定期的なアクセシビリティ監査を実施するとき
- 大規模な UI リファクタリング後にアクセシビリティを検証するとき
- WCAG 2.1 AA 認証に備えるとき
- アクセシビリティのベストプラクティスをチームに導入するとき
監査の方法
体系的なアクセシビリティ監査は、次のフェーズに沿って進めます。
フェーズ 1:事前調査
目的: コンポーネントを把握し、アクセシビリティ上重要な領域を特定します。
discover を使用して UI の範囲を把握します:
discover:
queries:
- id: interactive_components
type: grep
pattern: "(button|input|select|textarea|a href)"
glob: "**/*.{tsx,jsx}"
- id: form_components
type: grep
pattern: "<form|onSubmit|FormProvider"
glob: "**/*.{tsx,jsx}"
- id: aria_usage
type: grep
pattern: "aria-(label|labelledby|describedby|live|role)"
glob: "**/*.{tsx,jsx}"
- id: image_components
type: grep
pattern: "(<img|<Image|next/image)"
glob: "**/*.{tsx,jsx}"
verbosity: files_only
重要な領域を特定します:
- フォームと入力フィールド
- ナビゲーションメニューとルーティング
- モーダルダイアログとオーバーレイ
- データテーブルとリスト
- メディアプレーヤー(音声/動画)
- カスタムのインタラクティブウィジェット
フェーズ 2:セマンティック HTML 監査
目的: HTML5 のセマンティック要素が適切に使用されていることを確認します。
見出しの階層を確認する
見出しの使用箇所を検索します:
precision_grep:
queries:
- id: heading_elements
pattern: "<h[1-6]|heading.*level"
glob: "**/*.{tsx,jsx}"
output:
format: standard
よくある違反:
- 見出しレベルを飛ばしている(h1 から h2 を使わずに h3 へ進む)
- 1 ページ内に複数の h1 要素がある
- 構造ではなく見た目のスタイル設定に見出しを使用している
- 複雑なコンポーネントで見出しの階層が欠けている
適切な見出し構造:
import { ReactNode } from 'react';
interface PageLayoutProps {
title: string;
children: ReactNode;
}
export function PageLayout({ title, children }: PageLayoutProps) {
return (
<div className="page-layout">
{/* Single h1 per page */}
<h1 className="text-3xl font-bold">{title}</h1>
<main>
{children}
</main>
</div>
);
}
interface SectionProps {
title: string;
children: ReactNode;
}
export function Section({ title, children }: SectionProps) {
return (
<section>
{/* h2 for main sections */}
<h2 className="text-2xl font-semibold">{title}</h2>
{children}
</section>
);
}
interface SubsectionProps {
title: string;
children: ReactNode;
}
export function Subsection({ title, children }: SubsectionProps) {
return (
<div>
{/* h3 for subsections */}
<h3 className="text-xl font-medium">{title}</h3>
{children}
</div>
);
}
ランドマーク領域を確認する
ランドマークの使用箇所を検索します:
precision_grep:
queries:
- id: landmarks
pattern: "(<header|<main|<nav|<aside|<footer|role=\"(banner|navigation|main|complementary|contentinfo)\")"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
必須のランドマーク:
- サイトヘッダーには
<header>またはrole="banner" - ナビゲーションには
<nav>またはrole="navigation" - 主コンテンツには
<main>またはrole="main"(ページごとに必ず 1 つ) - サイドバーには
<aside>またはrole="complementary" - サイトフッターには
<footer>またはrole="contentinfo"
適切なランドマーク構造:
import { ReactNode } from 'react';
interface AppLayoutProps {
navigation: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
}
export function AppLayout({ navigation, sidebar, children }: AppLayoutProps) {
return (
<div className="app-layout">
<header className="site-header">
<div className="logo">MyApp</div>
{navigation}
</header>
<div className="content-container">
{sidebar && (
<aside className="sidebar" aria-label="Filters">
{sidebar}
</aside>
)}
{/* Exactly one main per page */}
<main className="main-content">
{children}
</main>
</div>
<footer className="site-footer">
<p>© 2026 MyApp. All rights reserved.</p>
</footer>
</div>
);
}
リストのセマンティクスを確認する
リストのパターンを検索します:
precision_grep:
queries:
- id: list_elements
pattern: "<(ul|ol|li|dl|dt|dd)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
よくある違反:
<ul>や<ol>の代わりに、リストに<div>を使用している<ul>や<ol>の外側に<li>を配置している- セマンティックなグループ化ではなく、レイアウトにリストを使用している
セマンティックなリストの使用:
interface User {
id: string;
name: string;
email: string;
}
interface UserListProps {
users: User[];
}
export function UserList({ users }: UserListProps) {
return (
<ul className="user-list" aria-label="Team members">
{users.map((user) => (
<li key={user.id}>
<span className="user-name">{user.name}</span>
<span className="user-email">{user.email}</span>
</li>
))}
</ul>
);
}
フェーズ 3:ARIA パターン監査
目的: ARIA の使い方とパターンが適切であることを検証します。
ARIA ロールを確認する
ARIA ロールの使用箇所を検索します:
precision_grep:
queries:
- id: aria_roles
pattern: 'role="(button|link|dialog|alertdialog|menu|menuitem|tab|tabpanel|listbox|option)"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
よくある違反:
- セマンティック要素に冗長なロールを指定している(例:
<button role="button">) - キーボードハンドラーを設定せずに
role="button"を使用している - 複雑なロールに必要な ARIA 属性が欠けている
- 抽象ロールを使用している(例:
role="widget")
適切な ARIA ボタンパターン:
import { MouseEvent, KeyboardEvent } from 'react';
interface CustomButtonProps {
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}
export function CustomButton({ onClick, disabled, children }: CustomButtonProps) {
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
if (!disabled) {
onClick();
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className="custom-button"
>
{children}
</div>
);
}
注: 可能な場合は、ネイティブの <button> 要素を優先してください。role="button" は、どうしても必要な場合に限り、ボタン以外の要素に使用してください。
ARIA ラベルと説明を確認する
ラベル付けのパターンを検索します:
precision_grep:
queries:
- id: aria_labels
pattern: "aria-(label|labelledby|describedby)"
glob: "**/*.{tsx,jsx}"
output:
format: context
よくある違反:
- アクセシブルな名前が付いていないインタラクティブ要素
- 存在しない ID を参照する
aria-labelledby - 冗長なラベル(
aria-labelとaria-labelledbyの両方がある) - インタラクティブでない要素に
aria-labelを使用している
適切なラベル付けパターン:
import { useId } from 'react';
interface SearchFormProps {
onSearch: (query: string) => void;
}
export function SearchForm({ onSearch }: SearchFormProps) {
const searchId = useId();
const hintId = useId();
return (
<form role="search" onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
onSearch(formData.get('query') as string);
}}>
<label htmlFor={searchId} className="sr-only">
Search articles
</label>
<input
id={searchId}
type="search"
name="query"
placeholder="Search..."
aria-describedby={hintId}
/>
<p id={hintId} className="text-sm text-gray-600">
Search by title, author, or keyword
</p>
<button type="submit" aria-label="Submit search">
<SearchIcon aria-hidden="true" />
</button>
</form>
);
}
ライブリージョンを確認する
ライブリージョンの使用箇所を検索します:
precision_grep:
queries:
- id: live_regions
pattern: 'aria-live="(polite|assertive|off)"|role="(status|alert)"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
ライブリージョンのベストプラクティス:
- 緊急性の低い更新には
aria-live="polite"を使用する - 緊急のメッセージには
aria-live="assertive"またはrole="alert"を使用する - ステータスの更新には
role="status"を使用する(暗黙的にaria-live="polite"が設定されます) - コンテンツが更新される前に、ライブリージョンが DOM に存在することを確認する
アクセシブルな通知パターン:
import { ReactNode, useEffect, useState } from 'react';
interface NotificationProps {
message: string;
type: 'success' | 'error' | 'info';
onDismiss: () => void;
}
export function Notification({ message, type, onDismiss }: NotificationProps) {
useEffect(() => {
const timer = setTimeout(onDismiss, 5000);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div
role={type === 'error' ? 'alert' : 'status'}
aria-live={type === 'error' ? 'assertive' : 'polite'}
className={`notification notification-${type}`}
>
<p>{message}</p>
<button onClick={onDismiss} aria-label="Dismiss notification">
<CloseIcon aria-hidden="true" />
</button>
</div>
);
}
フェーズ 4:キーボードナビゲーション監査
目的: キーボードだけで完全に操作できることを確認します。
フォーカス管理を確認する
フォーカス関連のコードを検索します:
precision_grep:
queries:
- id: focus_management
pattern: "(focus\\(\\)|autoFocus|tabIndex|useRef.*focus)"
glob: "**/*.{tsx,jsx,ts}"
output:
format: standard
よくある違反:
- フォーカスインジケーター(
:focusスタイル)がない - モーダルに、解除方法のないフォーカストラップがある
- 到達可能であるべきインタラクティブ要素に
tabIndex="-1"が設定されている - ページ読み込み時に、不必要に要素へ自動フォーカスしている
フォーカストラップを備えたアクセシブルなモーダル:
import { useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// Save previously focused element
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element in modal
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements && focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
// Trap focus within modal
if (e.key === 'Tab' && focusableElements) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
<div className="modal-body">{children}</div>
<button onClick={onClose} className="modal-close">
Close
</button>
</div>
</div>,
document.body
);
}
スキップリンクを確認する
スキップリンクの実装を検索します:
precision_grep:
queries:
- id: skip_links
pattern: "(skip.*main|skip.*content|skip.*navigation)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
スキップリンクの実装:
export function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
>
Skip to main content
</a>
);
}
// CSS (in global styles)
// .skip-link {
// position: absolute;
// top: -40px;
// left: 0;
// background: #000;
// color: #fff;
// padding: 8px;
// text-decoration: none;
// z-index: 100;
// }
//
// .skip-link:focus {
// top: 0;
// }
キーボードイベントハンドラーを確認する
キーボード操作に対応していない onClick を検索します:
precision_grep:
queries:
- id: onclick_handlers
pattern: "onClick=\\{"
glob: "**/*.{tsx,jsx}"
output:
format: locations
手動レビュー: ボタンやリンク以外の要素に設定された各 onClick ハンドラーについて、次を確認します:
- 要素に
role="button"または適切なロールが設定されている - キーボードフォーカス用に
tabIndex={0}が設定されている onKeyDownハンドラーが Enter キーと Space キーに応答する
アクセシブルなクリックハンドラーのパターン:
import { MouseEvent, KeyboardEvent } from 'react';
interface ClickableCardProps {
title: string;
onClick: () => void;
}
export function ClickableCard({ title, onClick }: ClickableCardProps) {
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className="clickable-card"
>
<h3>{title}</h3>
</div>
);
}
フェーズ 5:スクリーンリーダーサポートの監査
目的: スクリーンリーダーとの互換性を確認します。
視覚的に隠されたテキストを確認する
sr-only パターンを検索します:
precision_grep:
queries:
- id: sr_only
pattern: "(sr-only|visually-hidden|screen-reader)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: files_only
アイコン用の視覚的に隠されたテキスト:
interface IconButtonProps {
onClick: () => void;
ariaLabel: string;
icon: React.ReactNode;
}
export function IconButton({ onClick, ariaLabel, icon }: IconButtonProps) {
return (
<button onClick={onClick} aria-label={ariaLabel}>
{icon}
<span className="sr-only">{ariaLabel}</span>
</button>
);
}
// CSS for sr-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;
// }
aria-hidden の使い方を確認する
aria-hidden を検索します:
precision_grep:
queries:
- id: aria_hidden
pattern: 'aria-hidden="true"'
glob: "**/*.{tsx,jsx}"
output:
format: standard
よくある違反:
- インタラクティブ要素(ボタン、リンク)に
aria-hidden="true"が設定されている - アクセシブルであるべきコンテンツに
aria-hidden="true"が設定されている - 装飾用アイコンに
aria-hidden="true"が設定されていない
正しい aria-hidden の使い方:
interface ButtonWithIconProps {
onClick: () => void;
label: string;
}
export function ButtonWithIcon({ onClick, label }: ButtonWithIconProps) {
return (
<button onClick={onClick}>
{/* Icon is decorative, hide from screen readers */}
<CheckIcon aria-hidden="true" />
{/* Label provides accessible text */}
<span>{label}</span>
</button>
);
}
フェーズ 6:色とコントラストの監査
目的: 十分な色のコントラストを確保し、色だけで情報を伝えないようにします。
コントラスト比を確認する
ブラウザーの DevTools または外部ツールを使用します:
- Chrome DevTools:要素を検証 -> Accessibility ペイン -> コントラスト比
- Firefox DevTools:Inspector -> Accessibility パネル
- axe DevTools ブラウザー拡張機能
- Lighthouse のアクセシビリティ監査
WCAG 2.1 AA の要件:
- 通常のテキスト(< 18pt):コントラスト比 4.5:1 以上
- 大きなテキスト(>= 18pt または >= 14pt の太字):コントラスト比 3:1 以上
- UI コンポーネントとグラフィック:コントラスト比 3:1 以上
色の定義を検索します:
precision_grep:
queries:
- id: color_definitions
pattern: "(bg-|text-|color:|background:)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: count_only
アクセシブルなカラーパレット(Tailwind の例):
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
theme: {
extend: {
colors: {
// Accessible color palette with documented contrast ratios
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
600: '#0284c7', // 4.54:1 on white (WCAG AA)
700: '#0369a1', // 7.09:1 on white (WCAG AAA)
900: '#0c4a6e', // 13.14:1 on white
},
// Error color with good contrast
error: {
600: '#dc2626', // 4.51:1 on white
700: '#b91c1c', // 6.70:1 on white
},
// Success color
success: {
600: '#16a34a', // 4.51:1 on white
700: '#15803d', // 6.68:1 on white
},
},
},
},
};
export default config;
色だけで伝える情報を確認する
ステータスインジケーターを検索します:
precision_grep:
queries:
- id: status_colors
pattern: "(bg-red|bg-green|bg-yellow|text-red|text-green|text-yellow)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
手動レビュー: ステータスが複数の方法(色 + アイコン + テキスト)で伝わることを確認します。
アクセシブルなステータスインジケーター:
type Status = 'success' | 'warning' | 'error' | 'info';
interface StatusBadgeProps {
status: Status;
message: string;
}
const statusConfig: Record<Status, { icon: React.ReactNode; className: string }> = {
success: { icon: <CheckCircleIcon />, className: 'bg-green-100 text-green-800' },
warning: { icon: <AlertTriangleIcon />, className: 'bg-yellow-100 text-yellow-800' },
error: { icon: <XCircleIcon />, className: 'bg-red-100 text-red-800' },
info: { icon: <InfoIcon />, className: 'bg-blue-100 text-blue-800' },
};
export function StatusBadge({ status, message }: StatusBadgeProps) {
const { icon, className } = statusConfig[status];
return (
<div className={`flex items-center gap-2 px-3 py-2 rounded ${className}`}>
{/* Icon provides visual indicator beyond color */}
<span aria-hidden="true">{icon}</span>
{/* Text provides clear status information */}
<span>{message}</span>
</div>
);
}
強制カラーモードを確認する
forced-colors メディアクエリーを検索します:
precision_grep:
queries:
- id: forced_colors
pattern: "@media.*forced-colors|prefers-contrast"
glob: "**/*.{css,scss,tsx,jsx}"
output:
format: files_only
Windows のハイコントラストモードをサポートします:
/* Ensure borders are visible in forced-colors mode */
.card {
border: 1px solid #e5e7eb;
}
@media (forced-colors: active) {
.card {
border: 1px solid CanvasText;
}
}
/* Ensure custom controls are visible */
.custom-checkbox {
border: 2px solid #3b82f6;
}
@media (forced-colors: active) {
.custom-checkbox {
border: 2px solid ButtonText;
}
.custom-checkbox:checked {
background-color: Highlight;
}
}
フェーズ 7:フォームと入力検証の監査
目的: フォームがアクセシブルで、エラー処理が明確であることを確認します。
フォームラベルを確認する
入力要素を検索します:
precision_grep:
queries:
- id: input_elements
pattern: "<input|<textarea|<select"
glob: "**/*.{tsx,jsx}"
output:
format: locations
手動レビュー: 各入力に次のいずれかを介して関連付けられたラベルがあることを確認します:
htmlFor属性が一致する<label>aria-label属性- ラベル要素を参照する
aria-labelledby
よくある違反:
- ラベルがなく、プレースホルダーだけの入力
htmlFor属性がないラベル- 複数の入力で同じラベルを共有している
アクセシブルなフォームのパターン:
import { useId, FormEvent } from 'react';
interface FormData {
email: string;
password: string;
}
interface LoginFormProps {
onSubmit: (data: FormData) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const emailId = useId();
const passwordId = useId();
const emailErrorId = useId();
const passwordErrorId = useId();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await onSubmit({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
};
return (
<form onSubmit={handleSubmit} noValidate>
<div className="form-field">
{/* Explicit label with htmlFor */}
<label htmlFor={emailId}>
Email address
</label>
<input
id={emailId}
type="email"
name="email"
required
aria-required="true"
aria-describedby={emailErrorId}
autoComplete="email"
/>
{/* Error message linked via aria-describedby */}
<p id={emailErrorId} className="error-message" role="alert">
{/* Error text populated on validation */}
</p>
</div>
<div className="form-field">
<label htmlFor={passwordId}>
Password
</label>
<input
id={passwordId}
type="password"
name="password"
required
aria-required="true"
aria-describedby={passwordErrorId}
autoComplete="current-password"
/>
<p id={passwordErrorId} className="error-message" role="alert">
{/* Error text populated on validation */}
</p>
</div>
<button type="submit">
Sign in
</button>
</form>
);
}
エラー処理を確認する
エラーパターンを検索します:
precision_grep:
queries:
- id: error_messages
pattern: "(error|invalid|required).*message"
glob: "**/*.{tsx,jsx,ts}"
output:
format: standard
アクセシブルなエラー処理:
import { useState, useId } from 'react';
interface FieldError {
field: string;
message: string;
}
interface FormWithValidationProps {
onSubmit: (data: Record<string, string>) => void;
}
export function FormWithValidation({ onSubmit }: FormWithValidationProps) {
const [errors, setErrors] = useState<FieldError[]>([]);
const nameId = useId();
const nameErrorId = useId();
const errorSummaryId = useId();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
// Validation
const newErrors: FieldError[] = [];
if (!name || name.length < 2) {
newErrors.push({ field: 'name', message: 'Name must be at least 2 characters' });
}
if (newErrors.length > 0) {
setErrors(newErrors);
// Focus error summary for screen readers
document.getElementById(errorSummaryId)?.focus();
return;
}
setErrors([]);
onSubmit({ name });
};
const nameError = errors.find((e) => e.field === 'name');
return (
<form onSubmit={handleSubmit} noValidate>
{/* Error summary at top of form */}
{errors.length > 0 && (
<div
id={errorSummaryId}
role="alert"
aria-labelledby="error-summary-title"
className="error-summary"
tabIndex={-1}
>
<h2 id="error-summary-title">There are {errors.length} errors</h2>
<ul>
{errors.map((error) => (
<li key={error.field}>
<a href={`#${error.field}`}>{error.message}</a>
</li>
))}
</ul>
</div>
)}
<div className="form-field">
<label htmlFor={nameId}>Name</label>
<input
id={nameId}
name="name"
aria-required="true"
aria-invalid={nameError ? 'true' : 'false'}
aria-describedby={nameError ? nameErrorId : undefined}
/>
{nameError && (
<p id={nameErrorId} className="error-message" role="alert">
{nameError.message}
</p>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
必須フィールドを確認する
必須フィールドの表示を検索します:
precision_grep:
queries:
- id: required_fields
pattern: "(required|aria-required)"
glob: "**/*.{tsx,jsx}"
output:
format: files_only
アクセシブルな必須フィールドの表示:
interface RequiredFieldLabelProps {
htmlFor: string;
children: React.ReactNode;
}
export function RequiredFieldLabel({ htmlFor, children }: RequiredFieldLabelProps) {
return (
<label htmlFor={htmlFor}>
{children}
{/* Visual indicator */}
<span className="text-red-600" aria-hidden="true"> *</span>
{/* Screen reader text */}
<span className="sr-only"> (required)</span>
</label>
);
}
フェーズ 8:メディアアクセシビリティの監査
目的: 画像、動画、音声コンテンツがアクセシブルであることを確認します。
画像の代替テキストを確認する
画像を検索します:
precision_grep:
queries:
- id: img_elements
pattern: "(<img|<Image|next/image)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
手動レビュー: 各画像について、次を確認します:
- コンテンツ画像には意味のある
altテキストがある - 装飾画像には空の
alt=""が設定されている - リンク画像の代替テキストが、その機能を説明している
よくある違反:
alt属性がない- 汎用的な代替テキスト(「画像」、「写真」、「アイコン」)
- 冗長な代替テキスト(「〜の画像」、「〜の写真」)
- ファイル名を説明する代替テキスト(「IMG_1234.jpg」)
アクセシブルな画像のパターン:
import Image from 'next/image';
interface ProductImageProps {
src: string;
productName: string;
}
// Content image with descriptive alt
export function ProductImage({ src, productName }: ProductImageProps) {
return (
<Image
src={src}
alt={`${productName} product photo`}
width={400}
height={400}
/>
);
}
// Decorative image with empty alt
export function DecorativePattern() {
return (
<div className="background-pattern">
<Image
src="/patterns/dots.svg"
alt=""
fill
aria-hidden="true"
/>
</div>
);
}
// Functional image (icon button)
interface DeleteButtonProps {
onDelete: () => void;
}
export function DeleteButton({ onDelete }: DeleteButtonProps) {
return (
<button onClick={onDelete} aria-label="Delete item">
<Image
src="/icons/trash.svg"
alt=""
width={20}
height={20}
aria-hidden="true"
/>
</button>
);
}
動画のキャプションを確認する
動画要素を検索します:
precision_grep:
queries:
- id: video_elements
pattern: "(<video|<track)"
glob: "**/*.{tsx,jsx}"
output:
format: standard
キャプション付きのアクセシブルな動画:
interface AccessibleVideoProps {
src: string;
captionsSrc: string;
title: string;
}
export function AccessibleVideo({ src, captionsSrc, title }: AccessibleVideoProps) {
return (
<video controls aria-label={title}>
<source src={src} type="video/mp4" />
{/* Captions for deaf/hard of hearing users */}
<track
kind="captions"
src={captionsSrc}
srcLang="en"
label="English captions"
default
/>
{/* Fallback text */}
<p>
Your browser does not support the video element.
<a href={src}>Download the video</a>
</p>
</video>
);
}
動きの軽減設定を確認する
アニメーションを検索します:
precision_grep:
queries:
- id: animations
pattern: "(animate|transition|prefers-reduced-motion)"
glob: "**/*.{tsx,jsx,css,scss}"
output:
format: files_only
prefers-reduced-motion に対応します:
import { motion, useReducedMotion } from 'framer-motion';
interface AnimatedCardProps {
children: React.ReactNode;
}
export function AnimatedCard({ children }: AnimatedCardProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.3,
}}
>
{children}
</motion.div>
);
}
CSS による方法:
/* Default: animated */
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: scale(1.05);
}
/* Reduced motion: disable animations */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}
自動テスト
Precision ツールのワークフロー
包括的なアクセシビリティチェックを実行します:
discover:
queries:
# Missing alt attributes
- id: missing_alt
type: grep
pattern: "<img(?![^>]*alt=)"
glob: "**/*.{tsx,jsx}"
# onClick without keyboard handler
- id: onclick_no_keyboard
type: grep
pattern: 'onClick=\\{(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Non-button with role=button without keyboard
- id: role_button_no_keyboard
type: grep
pattern: 'role="button"(?!.*onKeyDown)'
glob: "**/*.{tsx,jsx}"
# Missing form labels
- id: unlabeled_inputs
type: grep
pattern: "<input(?![^>]*aria-label)(?![^>]*id=)"
glob: "**/*.{tsx,jsx}"
# Heading level skips
- id: heading_usage
type: grep
pattern: "<h[1-6]"
glob: "**/*.{tsx,jsx}"
verbosity: locations
ブラウザーベースのテスト
包括的な自動テストを行うには、次のツールを実行します。
-
Lighthouse アクセシビリティ監査
npx lighthouse https://localhost:3000 --only-categories=accessibility --view -
axe DevTools
- インストール:axe DevTools Chrome Extension
- 実行:F12 -> axe DevTools -> Scan
-
Pa11y CI
npm install --save-dev pa11y-ci npx pa11y-ci --sitemap https://yoursite.com/sitemap.xml
監査レポート
WCAG の達成基準に基づいて問題を整理する
レポートのテンプレート:
# Accessibility Audit Report
**Date:** 2026-02-16
**Auditor:** [Name]
**WCAG Version:** 2.1 Level AA
**Pages Audited:** 12
## Executive Summary
- **Critical Issues:** 3
- **Serious Issues:** 8
- **Moderate Issues:** 15
- **Minor Issues:** 22
## Findings by WCAG Principle
### 1. Perceivable
#### 1.1.1 Non-text Content (Level A) - FAIL
**Issue:** 12 images missing alt text
**Location:**
- `src/components/ProductCard.tsx:45`
- `src/components/Gallery.tsx:78`
**Impact:** Screen reader users cannot understand image content
**Recommendation:**
```typescript
// Before
<img src="/product.jpg" />
// After
<img src="/product.jpg" alt="Blue cotton t-shirt" />
1.4.3 コントラスト(最低限)(レベル AA)- 不合格
問題: プライマリーボタンのテキストのコントラスト比が 3.2:1 です(4.5:1 が必要です)。
場所: src/styles/globals.css:45
影響: 弱視のユーザーはボタンのテキストを読めない可能性があります。
推奨事項:
/* Before: #60a5fa on #3b82f6 = 3.2:1 */
.btn-primary {
background: #3b82f6;
color: #60a5fa;
}
/* After: white on #2563eb = 4.5:1 */
.btn-primary {
background: #2563eb;
color: #ffffff;
}
2. 操作可能
2.1.1 キーボード(レベル A)- 不合格
問題: カスタムドロップダウンがキーボードで操作できません。
場所: src/components/Dropdown.tsx
影響: キーボードユーザーはドロップダウンを操作できません。
推奨事項: 矢印キーでの移動と Enter/Space キーでの有効化を実装します。
3. 理解可能
3.3.2 ラベルまたは説明(レベル A)- 不合格
問題: フォーム入力に視認できるラベルがありません。
場所: src/components/ContactForm.tsx:23-45
推奨事項: htmlFor 属性を指定した明示的な <label> 要素を追加します。
4. 堅牢
4.1.2 名前、役割、値(レベル A)- 不合格
問題: カスタムチェックボックスに ARIA の状態が設定されていません。
場所: src/components/CustomCheckbox.tsx
推奨事項: aria-checked 状態と role="checkbox" を追加します。
優先対応事項
- 緊急: すべての画像に代替テキストを追加する(スクリーンリーダーユーザーの 100% に影響します)
- 緊急: ドロップダウンのキーボード操作を修正する(キーボードユーザーが操作できなくなっています)
- 高: プライマリーボタンの色のコントラストを修正する(ユーザーの約 4.5% に影響します)
- 高: フォームラベルを追加する(スクリーンリーダーを利用するユーザーと認知障害のあるユーザーに影響します)
## Related Skills
- **code-review** - Apply accessibility checks during code review
- **component-architecture** - Design accessible component APIs
- **testing-strategy** - Include accessibility in test suites
## References
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)