アクセシビリティを後付けした僕たちの失敗──3年のWebチーム実装記
SOC2審査で発覚したアクセシビリティ地獄。後付けに3倍の工数がかかった失敗から学んだ、実装フェーズから組み込むべき設計パターンをコード例で紹介。
「多言語対応より後回しにされた」僕たちのアクセシビリティ戦争
先日、プロジェクトレビューでスクリーンリーダーのテスト結果を見たら、実装済みの新機能が全く読み上げられていなかった。その時点で僕たちは本番環境に2ヶ月リリースを控えていた。正直、気が遠くなった。
うちのチームは3年前からWebアプリケーション開発をしてるんだけど、最初の1.5年はアクセシビリティなんて完全に無視していた。「後でなんとかしよう」って精神で、デザイン・機能実装・パフォーマンス最適化を優先していたんだ。
その結果どうなったか。去年SOC2審査に引っかかって、本格的にアクセシビリティに取り組まざるを得なくなった。それからの半年は、本当に地獄だった。既存コードの改修は新機能開発の3倍時間がかかるし、スクリーンリーダーのテストは自動化が難しいし、何より「アクセシビリティって何?」の状態から全員が学び直す羽目になった。
ここからが本題。2026年時点で、実務レベルでアクセシビリティを正しく実装するには何をすべきか。僕たちが痛い目を見た失敗と、ようやく安定してきた運用パターンをシェアしたい。
「後付け地獄」を避ける──アーキテクチャレベルの設計から入れ
最初の誤解は「HTMLを正しく書いてれば大丈夫」だと思ってたこと。これは本当に甘かった。
2026年のWebフロントエンドはコンポーネント駆動だから、各コンポーネントレベルでアクセシビリティを考慮しないと、後から全体を修正するハメになるんだ。僕たちはReact + Next.js 15で開発してるんだけど、最初からすべてのコンポーネントに以下を組み込むようにした。
// ❌ ダメな例:onclick だけで実装したボタン
const BadButton = ({ children, onClick }) => (
<div onClick={onClick} role="button">
{children}
</div>
);
// ✅ 良い例:aria属性とキーボードナビゲーション対応
const AccessibleButton = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ children, ...props }, ref) => (
<button
ref={ref}
type="button"
aria-label={props['aria-label']}
{...props}
>
{children}
</button>
));
これだけで見ると「当たり前じゃん」って思うかもしれないけど、実装チーム全体がこのルールを守るのは想像以上に大変だった。デザインシステムのレベルで強制しないと、時間がないプロジェクトから順に例外が増えていくんだよ。
うちは3ヶ月目からデザインシステムのコンポーネント検証に自動テストを必須化した。これが地味に効いた。
// jest + @testing-library/react で a11y テスト自動化
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Button コンポーネントが WCAG2.1 AA に準拠', async () => {
const { container } = render(
<Button aria-label="保存">保存する</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
これを導入してから、デザインシステム側でa11y違反が本当に減った。新しくコンポーネント追加する際も、テストが通らなければマージできないようにしたから、チーム全体の品質が自動的に上がるんだ。
スクリーンリーダーテスト──自動化できる部分と「人手必須」の壁
ここで現実的な話をしたい。2026年時点でも、スクリーンリーダーの動作確認は完全自動化できない。NVDA(Windows)、JAWS、VoiceOver(macOS/iOS)を実際に動かして「読まれ順序が正しいか」「変なアナウンスがないか」を確認する必要があるんだ。
うちのチームは自動テストと人手テストを分けることにした。以下のテーブルが現在のやり方。
| テスト項目 | 自動化 | ツール | 頻度 |
|---|---|---|---|
| DOM構造・ARIA属性 | ✅ | jest-axe / Lighthouse | 全コミット |
| 色コントラスト比 | ✅ | contrast-checker | PR時 |
| スクリーンリーダー読上順序 | ❌ | 手動(NVDA/VO) | 新機能・大修正 |
| キーボード操作 | △ | パーシャル(PlaywrightでTab操作) | PR時 |
| フォーカス管理 | ❌ | 手動 | PR時 |
最初は「スクリーンリーダー対応って月2日くらい作業が増えるのか」と甘く見てたけど、実際には以下の理由で想定外に時間がかかるんだ。
1. スクリーンリーダーのバージョン差異
同じコードでもブラウザ・OS・スクリーンリーダーの組み合わせで動作が違う。僕たちはNVDA + Firefox、VoiceOver + Safari の組み合わせだけは最低限テストするようにしてる。
2. aria-live 領域の落とし穴
動的に更新される領域(エラーメッセージ、通知など)には aria-live="polite" や aria-live="assertive" が必要だけど、これが機能しているか確認するには実際にスクリーンリーダーで試すしかないんだよ。自動化できないのが辛い。
3. フォーカス管理の複雑性
モーダルダイアログやドロップダウンメニューを開いた時、フォーカスをどこに移動させるか。これも自動では判定できない。経験と知識が必要になってくる。
// ❌ ダメな例:フォーカス管理がない
const Modal = ({ isOpen, children, onClose }) => {
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true">
{children}
<button onClick={onClose}>閉じる</button>
</div>
);
};
// ✅ 良い例:useEffect で初期フォーカスを管理
const AccessibleModal = ({ isOpen, children, onClose, initialFocusRef }) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && initialFocusRef?.current) {
initialFocusRef.current.focus();
}
if (isOpen && !initialFocusRef && modalRef.current) {
// デフォルトは最初のフォーカス可能要素
const focusableElement = modalRef.current.querySelector(
'button, [href], input, select, textarea, [tabindex]'
) as HTMLElement;
focusableElement?.focus();
}
}, [isOpen, initialFocusRef]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{children}
<button onClick={onClose}>閉じる</button>
</div>
);
};
2026年のa11yスタック──ツール選定の実務判断
ここ1年で、a11y検証ツールがすごく進化した。自動化できる部分はこれらを組み合わせるのが効率的だと思ってる。
開発フェーズ:jest-axe + Lighthouse CI
jest-axeはコンポーネント単位でWCAGルール違反を検出できる。うちはこれをCI/CDに組み込んで、違反があればビルド失敗にした。導入も簡単。
npm install --save-dev jest-axe @testing-library/react
そしてLighthouse CIで、本番に近い環境でのa11y スコアをチェック。これは月1回のスケジュールチェックで十分かな。
QA フェーズ:Axe DevTools + WAVE
ブラウザ拡張のAxe DevTools(Chrome/Firefox)は、ページ全体をスキャンして違反をレポート出力できる。色コントラストの自動チェックも入ってるから地味に便利。
WAVEも同様だけど、個人的にはAxeの方がフレームワーク対応がいい印象。どちらでもいいけど。
スクリーンリーダーテスト:NVDA(無料)
企業向けJAWSは高いから、僕たちはNVDAをメイン環境にした。Windows + Firefox/Chrome の組み合わせで、ほぼ本番環境がカバーできるんだ。
テスト手順も標準化した。新機能ごとに以下のチェックリストを実行してる。
## スクリーンリーダーテストチェックリスト
### 基本ナビゲーション
- [ ] ページを読み込んで、最初に「何のページか」がアナウンスされるか
- [ ] Tab キーで全てのインタラクティブ要素に到達できるか
- [ ] フォーカス順序は論理的か(左上から右下へ)
### フォーム操作
- [ ] ラベルが正しく関連付けられているか(aria-label または <label for>)
- [ ] 入力エラーが読み上げられるか
- [ ] 入力必須マークが読み上げられるか
### 動的更新
- [ ] 新しい通知やエラーが aria-live で読み上げられるか
- [ ] ページの一部が更新されても、アナウンスが重複しないか
### 画像・メディア
- [ ] すべての画像に意味のある alt テキストがあるか
- [ ] 装飾的な画像に `aria-hidden="true"` が付いているか
- [ ] 動画に字幕があるか
色コントラスト──単純だけど落とし穴が多い
WCAG 2.1では、テキストと背景の色コントラスト比が定義されてる。基準は以下のとおり。
- AA レベル:通常テキスト 4.5:1 以上、大きいテキスト(18pt以上) 3:1 以上
- AAA レベル:通常テキスト 7:1 以上、大きいテキスト 4.5:1 以上
自動テストで検出できるから、Lighthouse CI や jest-axe で毎回チェックすべき。ここは割と簡単に対応できる。
ただし、落とし穴がある。グラデーション背景とか複雑な背景の場合、自動検出がうまくいかないんだ。
// ❌ 自動テストで検出されない(文字に背景なし、かつグラデーション)
<div style={{ background: 'linear-gradient(to right, white, gray)' }}>
<span style={{ color: '#777' }}>中程度のテキスト</span>
</div>
// ✅ コントラスト比をテストできる(固定色)
<div style={{ backgroundColor: '#f5f5f5' }}>
<span style={{ color: '#333' }}>中程度のテキスト</span>
</div>
複雑な背景を使う場合は、手動で色コントラスト比を計算・確認する必要がある。WebAIM Contrast Checkerがおすすめ。デザイナーと共有しておくといい。
キーボードナビゲーション──「マウスなしで全操作できる」の実装
もう一つの大事な要素だ。マウスが使えない人、または手が不自由な人向けに、キーボードだけで全操作ができるべき。
特に大変なのはドロップダウンメニューとデータテーブル。この辺りは実装が複雑になる。
// ドロップダウンメニューの a11y 実装例
const Dropdown = () => {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [focusedIndex, setFocusedIndex] = useState(-1);
const items = [
{ label: '編集', onClick: () => {} },
{ label: '削除', onClick: () => {} },
];
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((prev) =>
prev < items.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((prev) =>
prev > 0 ? prev - 1 : items.length - 1
);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (focusedIndex >= 0) {
items[focusedIndex].onClick();
setIsOpen(false);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
ref={menuRef}
role="listbox"
aria-expanded={isOpen}
onKeyDown={isOpen ? handleKeyDown : undefined}
>
{/* ボタンとメニュー */}
</div>
);
};
ただし、このレベルの複雑性があるなら、ライブラリ(Headless UI、Radix UI など)を使った方が堅実だと思う。僕たちも最近はRadix UIに統一して、キーボード操作はライブラリ側に任せるようにした。こっちの方がメンテナンスも楽だし。
import * as Select from '@radix-ui/react-select';
<Select.Root>
<Select.Trigger aria-label="選択">
<Select.Value placeholder="選んでください" />
</Select.Trigger>
<Select.Content>
<Select.Item value="option1">オプション1</Select.Item>
<Select.Item value="option2">オプション2</Select.Item>
</Select.Content>
</Select.Root>
まとめ
アクセシビリティは「最後に付け足すもの」ではなく、最初から設計に組み込まないと現実的に対応できない。これが僕たちが3年かかって学んだ一番の教訓だ。
実務レベルでの優先順位はこんな感じ。
- デザインシステムレベルで a11y を強制する:コンポーネント検証に jest-axe を必須化。違反があればビルド失敗。
- 自動テストと人手テストを明確に分ける:色コントラスト・ARIA属性・DOM構造は自動化。スクリーンリーダー・フォーカス管理・キーボード操作は手動テスト。
- キーボード操作はライブラリに任せる:Radix UI や Headless UI など、a11y が実装されたコンポーネントライブラリを採用。
- 本番リリース前にスクリーンリーダーテストを必須化:NVDA など無料ツールでもいいから、最低限の確認は入れる。
- 色コントラスト比は自動チェック + 手動確認:グラデーション背景など複雑な場合は WebAIM Contrast Checker で確認。
特にスタートアップや小規模チームの場合、「完璧な a11y」を目指すと時間がなくなる。段階的に、まずは WCAG 2.1 AA レベルを目指すのが現実的だと思ってる。その上で、ユーザーフィードバックを基に改善していく方針がおすすめ。
最後に。次のアクション:既存プロジェクトに jest-axe を導入して、デザインシステムコンポーネントからa11y テストを始めてみてください。最初は検出される違反が多くて げんなりするかもだけど、それが改善の第一歩だと思います。