web-accessibility について
Web インターフェースの構築やリファクタリングを行う人やエージェント向けに、WCAG 2.1 レベル AA の実装指針を示すスキルです。セマンティック HTML、キーボード操作、ARIA、フォーム、テストを扱い、チェックリストと機械的に検査できるルールも備えています。
主な機能
- 01WCAG 2.1 AA 実装ガイド
- 02セマンティック HTML と ARIA のパターン
- 03キーボード操作とフォーカス管理
- 04フォーム、コントラスト、メディアの指針
- 05自動・手動テストのチェックリスト
活用シーン
- 01新しい Web インターフェースの構築
- 02アクセシビリティ上の問題の確認と修正
- 03キーボード操作可能なモーダルの設計
- 04フォームや動的コンテンツの支援技術対応の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ alexanderstephenthompson/ claude-hub --skill web-accessibilityインストール後、Claude Code で/web-accessibilityを入力して読み込まれたか確認します。
詳細
Web アクセシビリティスキル
バージョン: 1.0 標準: WCAG 2.1 レベル AA
アクセシビリティは任意ではありません。これらのパターンにより、すべてのユーザーがアプリケーションを利用できます。
問題点
AI エージェントは、見た目は正しくてもアクセシブルではないインターフェースを構築することがあります。WCAG の基準が明示されていないと、セッションごとに代替テキストが省略され、ボタンの代わりに div が使われ、見出しが誤った階層でネストされ、キーボード操作への対応が抜けてしまいます。意図的にそうしているのではなく、最も手間のかからない方法を選んでしまうためです。セッションごとにアクセシビリティ上の不備が少しずつ異なり、修正が終わりのない作業になります。これらの基準に従えば、最初の要素からアクセシビリティを組み込めます。
利用方法
- 構築者: HTML またはコンポーネントのマークアップを書く前に
## Builder Checklistを読んでください。アクセシビリティは設計段階で組み込む必要があり、後から ARIA で補うものではありません。 - リファクタリング担当者: アクセシビリティ違反を見つけるには
## Enforced Rulesを使ってください。修正パターンについては説明セクションを読んでください。 - 両者: 説明セクションが準拠すべき基準です。チェックリストとルール表は、同じ内容を簡潔にまとめたものです。
対象範囲と境界
このスキルの対象:
- WCAG 2.1 レベル AA への準拠
- POUR 原則(知覚可能、操作可能、理解可能、堅牢)
- アクセシビリティのためのセマンティック HTML(ランドマーク、見出し、ボタンとリンクの使い分け)
- キーボード操作とフォーカス管理
- モーダルのフォーカストラップ
- ARIA の使用パターンとロール
- フォームのアクセシビリティ(ラベル、エラー、必須項目)
- 色のコントラスト要件
- 画像やメディアの代替テキスト
- 動的コンテンツとライブリージョン
- 動きの低減への対応
- スクリーンリーダーでのテスト
他のスキルに委ねる内容:
design:デザイントークンシステム、全体的なデザイン原則、コンポーネントの状態web-css:CSS の実装詳細、ファイル構成、レスポンシブのブレークポイント
WCAG への準拠、スクリーンリーダーへの対応、フォーカス管理、または ARIA のパターンが必要な場合: このスキルを使用してください。 デザインシステムの原則、トークンの適用、またはレイアウトの考え方が必要な場合: design を使用してください。 CSS アーキテクチャまたはレスポンシブ対応の実装が必要な場合: web-css を使用してください。
基本原則(POUR)
- 知覚可能 — ユーザーがすべてのコンテンツを知覚できます(見たり、聞いたり、感じたりできます)。
- 操作可能 — ユーザーがすべてのコントロールを操作できます(キーボード、マウス、音声など)。
- 理解可能 — ユーザーがコンテンツとインターフェースの動作を理解できます。
- 堅牢 — 現在および将来の支援技術でコンテンツが動作します。
まずセマンティック HTML を使う
適切な要素を使う
<header>Site header</header>
<nav>Navigation</nav>
<main>
<article>
<h1>Article Title</h1>
<p>Content...</p>
</article>
<aside>Related content</aside>
</main>
<footer>Site footer</footer>
<div class="header">Site header</div>
<div class="nav">Navigation</div>
<div class="main">
<div class="article">
<div class="title">Article Title</div>
<div class="content">Content...</div>
</div>
</div>
ボタンとリンクの使い分け
// ✅ Button - Performs an action
<button onClick={handleSubmit}>Submit Form</button>
<button onClick={openModal}>Open Settings</button>
// ✅ Link - Navigates somewhere
<a href="/products">View Products</a>
<Link to="/checkout">Proceed to Checkout</Link>
// ❌ Bad - Wrong semantics
<div onClick={handleSubmit}>Submit Form</div>
<a onClick={openModal}>Open Settings</a> {/* No href! */}
<button onClick={() => navigate('/products')}>View Products</button>
見出しの階層
// ✅ Good - Proper hierarchy
<h1>Page Title</h1>
<section>
<h2>Section Title</h2>
<h3>Subsection</h3>
</section>
<section>
<h2>Another Section</h2>
</section>
// ❌ Bad - Skipped levels
<h1>Page Title</h1>
<h3>Subsection</h3> {/* Skipped h2! */}
<h5>Deep section</h5> {/* Skipped h4! */}
キーボード操作
フォーカス管理
/* Visible focus indicator */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove outline only for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
タブ順序
// ✅ Good - Logical tab order (follows DOM order)
<form>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
<label htmlFor="password">Password</label>
<input id="password" type="password" />
<button type="submit">Login</button>
</form>
// ❌ Bad - Jumpy tab order
<form>
<button type="submit" tabIndex={1}>Login</button>
<input tabIndex={3} />
<input tabIndex={2} />
</form>
スキップリンク
// At the very top of your app
function SkipLink() {
return (
<a href="#main-content" className="skip-link">
Skip to main content
</a>
);
}
// CSS
.skip-link {
position: absolute;
top: -100%;
left: var(--space-4);
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
z-index: var(--z-tooltip);
}
.skip-link:focus {
top: var(--space-4);
}
キーボードショートカット
// Listen for keyboard events
function SearchModal({ isOpen, onClose }) {
useEffect(() => {
function handleKeyDown(e) {
if (e.key === 'Escape') {
onClose();
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
// ...
}
フォーカストラップ(モーダル)
モーダルのパターン
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
// Store current focus
previousFocus.current = document.activeElement;
// Focus the modal
modalRef.current?.focus();
// Trap focus inside modal
const modal = modalRef.current;
const focusableElements = modal?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements?.[0];
const lastElement = focusableElements?.[focusableElements.length - 1];
function handleTab(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
modal?.addEventListener('keydown', handleTab);
return () => modal?.removeEventListener('keydown', handleTab);
} else {
// Restore focus when closing
previousFocus.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
×
</button>
</div>
</div>,
document.body
);
}
ARIA の使用
ARIA を使う場合
ARIA の第一原則: ネイティブ HTML で実現できる場合は、ARIA を使わないでください。
// ❌ Unnecessary ARIA
<div role="button" tabIndex={0} onClick={handleClick}>
Click me
</div>
// ✅ Just use a button
<button onClick={handleClick}>Click me</button>
必須の ARIA パターン
// Labeling
<button aria-label="Close menu">×</button>
<input aria-labelledby="name-label helper-text" />
// Descriptions
<button aria-describedby="delete-warning">Delete Account</button>
<p id="delete-warning">This action cannot be undone.</p>
// States
<button aria-pressed={isActive}>Toggle</button>
<button aria-expanded={isOpen} aria-controls="menu">Menu</button>
<div id="menu" aria-hidden={!isOpen}>Menu content</div>
// Live regions (for dynamic content)
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
よく使われる ARIA ロール
| ロール | 使用例 |
|---|---|
alert | 重要なメッセージ(エラー、警告) |
alertdialog | ユーザーの応答が必要なモーダル |
dialog | モーダルダイアログ |
navigation | ナビゲーションセクション |
search | 検索フォーム |
tablist、tab、tabpanel | タブインターフェース |
menu、menuitem | ドロップダウンメニュー |
status | ステータスの更新(読み込み中、保存中) |
フォーム
ラベル
// ✅ Explicit label association
<label htmlFor="email">Email Address</label>
<input id="email" type="email" />
// ✅ Implicit association (wrapped)
<label>
Email Address
<input type="email" />
</label>
// ❌ Bad - No association
<span>Email Address</span>
<input type="email" />
エラーメッセージ
function FormField({ id, label, error, ...props }) {
const errorId = `${id}-error`;
return (
<div className="form-field">
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="error-message" role="alert">
{error}
</p>
)}
</div>
);
}
必須項目
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
<span className="visually-hidden">(required)</span>
</label>
<input id="name" required aria-required="true" />
色とコントラスト
最小コントラスト比
| テキストの種類 | 比率 | 例 |
|---|---|---|
| 通常のテキスト(< 18px) | 4.5:1 | 本文 |
| 大きなテキスト(≥ 18px または 14px の太字) | 3:1 | 見出し |
| UI コンポーネント | 3:1 | ボタン、入力欄 |
色だけに頼らない
// ❌ Bad - Color is only indicator
<span style={{ color: error ? 'red' : 'green' }}>
{error ? 'Invalid' : 'Valid'}
</span>
// ✅ Good - Color + icon + text
<span className={error ? 'error' : 'success'}>
{error ? (
<>
<ErrorIcon aria-hidden="true" /> Invalid: {error}
</>
) : (
<>
<CheckIcon aria-hidden="true" /> Valid
</>
)}
</span>
画像とメディア
代替テキスト
// Informative image
<img src="chart.png" alt="Sales increased 40% from January to March" />
// Decorative image
<img src="divider.png" alt="" role="presentation" />
// Complex image
<figure>
<img src="diagram.png" alt="Architecture diagram" aria-describedby="diagram-desc" />
<figcaption id="diagram-desc">
The system consists of three layers: presentation, business logic, and data.
{/* Full description */}
</figcaption>
</figure>
SVG アイコン
// Decorative icon (with visible text)
<button>
<SearchIcon aria-hidden="true" />
Search
</button>
// Standalone icon (needs label)
<button aria-label="Search">
<SearchIcon aria-hidden="true" />
</button>
// Icon with title
<svg role="img" aria-labelledby="icon-title">
<title id="icon-title">Search</title>
<path d="..." />
</svg>
動的コンテンツ
読み込み状態
function ProductList() {
const { data, loading, error } = useQuery(GET_PRODUCTS);
if (loading) {
return (
<div role="status" aria-live="polite">
<Spinner aria-hidden="true" />
<span className="visually-hidden">Loading products...</span>
</div>
);
}
if (error) {
return (
<div role="alert">
Error loading products. Please try again.
</div>
);
}
return (
<ul aria-label="Products">
{data.products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
ライブリージョン
// Polite - Waits for user to finish current task
<div aria-live="polite" aria-atomic="true">
{saveStatus} {/* "Saving...", "Saved!", etc. */}
</div>
// Assertive - Interrupts immediately (use sparingly)
<div aria-live="assertive" role="alert">
{errorMessage}
</div>
動きの低減
/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// In React
function AnimatedComponent() {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
return (
<motion.div
animate={{ opacity: 1 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.3,
}}
/>
);
}
視覚的に隠すテキスト
.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;
}
// Use for screen reader only content
<button>
<TrashIcon aria-hidden="true" />
<span className="visually-hidden">Delete item</span>
</button>
<a href="/products">
View all products
<span className="visually-hidden"> in the catalog</span>
</a>
アクセシビリティのテスト
自動テスト
// jest + jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<ProductCard product={mockProduct} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
手動テストのチェックリスト
- キーボードのみでページ全体を操作する
- スクリーンリーダー(VoiceOver、NVDA)を使用する
- 200% に拡大しても使いやすいか確認する
- ブラウザーのアクセシビリティインスペクターで確認する
- ハイコントラストモードでテストする
- 視差効果を減らす設定を有効にしてテストする
アンチパターン
| アンチパターン | 問題 | 修正方法 |
|---|---|---|
| あらゆるものにdivを使う | 支援技術向けのセマンティクスがありません | セマンティックHTMLを使用する |
| tabIndex > 0 | 自然なタブ順序が崩れます | 正のtabIndexを削除する |
| Outline: none | フォーカスインジケーターがありません | :focus-visibleを使用する |
| ARIAの過剰な使用 | 複雑で、エラーが起きやすくなります | まずネイティブHTMLを使用する |
| 色だけで意味を伝える | 色覚異常のある人には見分けられません | アイコンやテキストを追加する |
| メディアの自動再生 | 混乱や不快感を招きます | ユーザーが開始した場合のみ再生する |
| マウスのみの操作 | キーボードユーザーを排除します | キーボード操作のイベントハンドラーを追加する |
| 代替テキストがない | スクリーンリーダーでは画像が認識できません | 画像を説明するか、装飾としてマークする |
| 説明のないリンク | 「ここをクリック」では役に立ちません | 内容がわかるリンクテキストにする |
ビルダー向けチェックリスト
このスキルが適用されるUIコードを書く前に、これらの制約に照らして計画を確認してください。ビルダーはコードを書く前にこのセクションを読み、リファクタリング担当者は「適用ルール」テーブルと全体の説明を使用します。
セマンティックHTML
- 見出しの階層が正しい
- 操作にはボタン、ナビゲーションにはリンクを使用する
- セマンティックなランドマーク(header、nav、main、footer)を使用する
- リストにはul/ol/liを使用する
キーボード
- すべてのインタラクティブ要素にフォーカスできる
- フォーカスインジケーターが見える
- タブ順序が論理的である
- スキップリンクがある
- モーダル内でフォーカスがトラップされる
スクリーンリーダー
- すべての画像に代替テキストがある
- フォーム入力にラベルがある
- エラーメッセージが入力欄に関連付けられている
- 動的コンテンツにライブリージョンを使用している
- アイコンにアクセシブルな名前がある
視覚
- 色のコントラスト比が4.5:1を満たしている
- 色だけを唯一の指標にしていない
- 200%の拡大表示で動作する
- 視差効果を減らす設定が尊重される
フォーム
- すべての入力欄にラベルがある
- 必須項目が示されている
- エラーが明確に識別できる
- エラーメッセージが役に立つ
適用ルール
これらのルールは、check.js(clean-team)によって決定論的にチェックされます。これらの標準を更新する際は、対応するcheck.jsのルールも更新して内容を一致させてください。逆の場合も同様です。
| ルールID | 重大度 | チェック内容 |
|---|---|---|
img-alt-required | error | alt属性がない<img> |
title-required | error | <title>要素がない |
tabindex-no-positive | error | 正のtabindex値(タブ順序が崩れる) |
heading-order | warn | 見出しレベルが飛んでいる(h1 → h3) |
single-h1 | warn | 1ページ内に複数の<h1>要素がある |
no-div-as-button | warn | onclickハンドラーを持つ<div>/<span> |