アクセシビリティ (a11y)
アクセシビリティ (a11y)
Section titled “アクセシビリティ (a11y)”アクセシビリティ(a11y)は、すべてのユーザーがアプリケーションを利用できるようにするための設計原則です。Next.jsでは、セマンティックHTMLとARIA属性を使用してアクセシビリティを実装できます。
なぜアクセシビリティが必要なのか
Section titled “なぜアクセシビリティが必要なのか”アクセシビリティの問題
Section titled “アクセシビリティの問題”問題のあるコード:
// セマンティックでないHTMLexport default function Button() { return ( <div onClick={handleClick} style={{ cursor: 'pointer' }}> Click me </div> );}
// 問題点:// - スクリーンリーダーがボタンとして認識しない// - キーボード操作ができない// - フォーカス管理ができないアクセシビリティを考慮したコード:
// セマンティックなHTMLexport default function Button() { return ( <button onClick={handleClick} aria-label="Submit form"> Click me </button> );}
// メリット:// - スクリーンリーダーがボタンとして認識// - キーボード操作が可能// - フォーカス管理が可能メリット:
- 包括性: すべてのユーザーが利用可能
- 法的要件: WCAG準拠が法的に要求される場合がある
- SEO: 検索エンジンがコンテンツを理解しやすい
- ユーザー体験: すべてのユーザーにとって使いやすい
🚨 マサカリ1:Next.js特有の「Route Announcer」を無視している
Section titled “🚨 マサカリ1:Next.js特有の「Route Announcer」を無視している”SPA/Next.jsにおいて、ページ遷移(Client-side transition)はスクリーンリーダー利用者にとって「何も起きていない」ように感じられる最大の罠です。
Next.js 10以降は標準でルートアナウンサーが内蔵されていますが、動的なタイトル更新が正しく行われていないと機能しません。layout.tsxでのMetadata設計とa11yの関連性を理解すべきです。
Route Announcerの仕組み
Section titled “Route Announcerの仕組み”Next.jsは、クライアントサイドでのページ遷移時に、以下の順序でページ名を検出してスクリーンリーダーに通知します:
document.title(メタデータのtitle)- ページ内の最初の
<h1>要素 - URLのパス名
重要なポイント:
layout.tsxでgenerateMetadataを使用して、各ページのtitleを適切に設定する必要があります- ページ内の最初の
<h1>要素が存在することが推奨されます
適切なMetadata設計
Section titled “適切なMetadata設計”export const metadata: Metadata = { title: { default: 'サイトタイトル', template: '%s | サイトタイトル', // 子ページのタイトルに追加されるテンプレート }, description: 'サイトの説明',};
// app/about/page.tsxexport const metadata: Metadata = { title: 'About', // 「About | サイトタイトル」として表示される description: 'Aboutページの説明',};
// app/blog/[slug]/page.tsxexport async function generateMetadata({ params: { slug },}: { params: { slug: string };}): Promise<Metadata> { const post = await getPost(slug);
return { title: post.title, // 動的なタイトル description: post.description, };}ページコンポーネントでの推奨構造:
export const metadata: Metadata = { title: 'About', description: 'Aboutページの説明',};
export default function AboutPage() { return ( <main> {/* ✅ 推奨: 最初のh1要素が存在する */} <h1>About Us</h1> <p>私たちについて...</p> </main> );}Route Announcerが機能しない場合
Section titled “Route Announcerが機能しない場合”❌ 問題のあるコード:
// ❌ 問題: titleが設定されていないexport default function HomePage() { return ( <div> {/* h1要素もない */} <h2>Welcome</h2> </div> );}// Route AnnouncerはURLパス名しか読み上げられない✅ 修正後のコード:
// ✅ 修正: titleとh1要素を設定export const metadata: Metadata = { title: 'Home', description: 'ホームページ',};
export default function HomePage() { return ( <main> <h1>Welcome</h1> {/* コンテンツ */} </main> );}// Route Announcerは「Home」または「Welcome」を読み上げるベストプラクティス
Section titled “ベストプラクティス”- すべてのページに
metadata.titleを設定: ルートアナウンサーが正しく動作する - ページ内の最初の要素を
<h1>にする: セマンティックな構造とRoute Announcerの両方に有効 - テンプレートを使用:
layout.tsxでtitle.templateを使用して、一貫性のあるタイトル構造を維持 - 動的なタイトル: 動的ルートでは
generateMetadataを使用して、各ページのタイトルを動的に生成
セマンティックHTML
Section titled “セマンティックHTML”適切なHTML要素の使用
Section titled “適切なHTML要素の使用”// 良い例: セマンティックなHTMLexport default function Article() { return ( <article> <header> <h1>記事のタイトル</h1> <p>公開日: 2024-01-01</p> </header> <main> <p>記事の内容...</p> </main> <footer> <p>著者: 田中太郎</p> </footer> </article> );}
// 悪い例: 非セマンティックなHTMLexport default function Article() { return ( <div> <div> <div>記事のタイトル</div> <div>公開日: 2024-01-01</div> </div> <div> <div>記事の内容...</div> </div> <div> <div>著者: 田中太郎</div> </div> </div> );}ARIA属性
Section titled “ARIA属性”aria-labelとaria-labelledby
Section titled “aria-labelとaria-labelledby”// アイコンボタンにラベルを追加export default function CloseButton() { return ( <button aria-label="閉じる"> <CloseIcon /> </button> );}
// 複雑な要素にラベルを関連付けexport default function SearchForm() { return ( <form> <label htmlFor="search-input">検索</label> <input id="search-input" type="search" aria-describedby="search-help" /> <span id="search-help">キーワードを入力してください</span> </form> );}aria-liveとaria-atomic
Section titled “aria-liveとaria-atomic”🚨 マサカリ2:aria-liveの使い方が危険
aria-live="polite"は便利ですが、むやみに使うとスクリーンリーダーが絶え間なく喋り続け、ユーザーを混乱させる「情報の暴力」になります。
実務では「フォーム送信の成功/失敗」や「検索結果の件数変化」など、本当に必要な箇所に絞る設計基準が必要です。
適切な使用例:
// ✅ 良い例: フォーム送信の成功/失敗のみ'use client';
import { useState } from 'react';
export default function ContactForm() { const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [message, setMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { // フォーム送信処理 setStatus('success'); setMessage('送信が完了しました'); } catch (error) { setStatus('error'); setMessage('送信に失敗しました。もう一度お試しください'); } };
return ( <form onSubmit={handleSubmit}> {/* フォームフィールド */} {status !== 'idle' && ( <div role="alert" aria-live={status === 'error' ? 'assertive' : 'polite'} > {message} </div> )} </form> );}
// ✅ 良い例: 検索結果の件数変化'use client';
import { useState, useEffect } from 'react';
export default function SearchResults({ query }: { query: string }) { const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { if (query) { setIsLoading(true); // 検索処理 fetchResults(query).then((data) => { setResults(data); setIsLoading(false); }); } }, [query]);
return ( <div> {!isLoading && ( <div aria-live="polite" aria-atomic="true" className="sr-only" > {results.length}件の検索結果が見つかりました </div> )} {/* 検索結果の表示 */} </div> );}❌ 悪い例: むやみにaria-liveを使用
// ❌ 悪い例: 頻繁に更新される要素にaria-liveを使用export default function BadExample() { const [count, setCount] = useState(0);
return ( <div> {/* 問題: カウントが更新されるたびにスクリーンリーダーが読み上げる */} <div aria-live="polite"> カウント: {count} </div> <button onClick={() => setCount(count + 1)}>+1</button> </div> );}
// ❌ 悪い例: 多数のaria-live領域を設定export default function BadExample() { return ( <div> <div aria-live="polite">通知1</div> <div aria-live="polite">通知2</div> <div aria-live="polite">通知3</div> {/* 問題: 複数のaria-live領域が競合し、ユーザーを混乱させる */} </div> );}ベストプラクティス:
aria-live="polite": 非緊急の更新(フォーム送信の成功、検索結果の件数など)aria-live="assertive": 緊急の更新(エラーメッセージ、セッションタイムアウトなど)のみ- 使用箇所の最小化: 本当に必要な箇所のみに絞る
role="alert"との組み合わせ: エラーメッセージにはrole="alert"を使用(aria-live="assertive"と同等)aria-atomic: 関連する情報をまとめて読み上げる場合に使用
使用例の判断基準:
| 更新内容 | aria-live | 理由 |
|---|---|---|
| フォーム送信の成功 | polite | ユーザーに通知するが、緊急ではない |
| フォーム送信の失敗 | assertive | エラーなので即座に通知が必要 |
| 検索結果の件数 | polite | 情報提供だが、緊急ではない |
| セッションタイムアウト | assertive | 緊急の通知が必要 |
| リアルタイムのカウント | 使用しない | 頻繁な更新は不要 |
| ローディング状態 | 使用しない | 視覚的な情報で十分 |
キーボード操作
Section titled “キーボード操作”フォーカス管理
Section titled “フォーカス管理”🚨 マサカリ3:「Headless UI」や「Radix UI」の推奨がない
自前でモーダルの「フォーカストラップ」を書くコード例がありますが、これは現代では**「アンチパターン」**に近いです。フォーカストラップを完璧に実装するのは極めて困難で、バグがa11yを破壊します。
プロは Radix UI や Headless UI などの、a11yが既に検証済みの「Headlessコンポーネント」をベースに使うことを推奨します。「車輪の再発明をせず、枯れたライブラリを使う」のが真のアクセシビリティ設計です。
❌ アンチパターン: 自前でフォーカストラップを実装
Section titled “❌ アンチパターン: 自前でフォーカストラップを実装”// ❌ 悪い例: 自前でフォーカストラップを実装(非推奨)'use client';
import { useEffect, useRef } from 'react';
export default function BadModal({ isOpen, onClose }: ModalProps) { // 問題: フォーカストラップの実装が複雑で、バグが発生しやすい // 問題: すべてのケースをカバーするのは困難 // 問題: アクセシビリティのベストプラクティスを完全に実装するのは困難
// ... 複雑なフォーカストラップのコード ...}✅ 推奨される方法: Headless UIコンポーネントライブラリを使用
Section titled “✅ 推奨される方法: Headless UIコンポーネントライブラリを使用”1. Radix UI(推奨)
Radix UIは、アクセシビリティが検証済みのHeadlessコンポーネントライブラリです。
セットアップ:
npm install @radix-ui/react-dialog使用例:
'use client';
import * as Dialog from '@radix-ui/react-dialog';import { Cross2Icon } from '@radix-ui/react-icons';
export default function AccessibleModal({ open, onOpenChange, trigger, title, description, children,}: { open?: boolean; onOpenChange?: (open: boolean) => void; trigger: React.ReactNode; title: string; description?: string; children: React.ReactNode;}) { return ( <Dialog.Root open={open} onOpenChange={onOpenChange}> <Dialog.Trigger asChild> {trigger} </Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay className="fixed inset-0 bg-black/50" /> <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg shadow-lg"> <Dialog.Title className="text-xl font-bold mb-2"> {title} </Dialog.Title> {description && ( <Dialog.Description className="text-gray-600 mb-4"> {description} </Dialog.Description> )} {children} <Dialog.Close asChild> <button className="absolute top-4 right-4" aria-label="閉じる" > <Cross2Icon /> </button> </Dialog.Close> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}
// 使用例<AccessibleModal trigger={<button>モーダルを開く</button>} title="モーダルのタイトル" description="モーダルの説明"> <p>モーダルのコンテンツ</p></AccessibleModal>メリット:
- ✅ フォーカストラップが自動的に実装される
- ✅ Escキーで閉じる機能が自動的に実装される
- ✅ アクセシビリティのベストプラクティスが既に実装されている
- ✅ ARIA属性が適切に設定される
- ✅ スクリーンリーダーに対応
2. Headless UI(Tailwind CSSと組み合わせやすい)
Headless UIは、Tailwind CSSの開発者が作成したHeadlessコンポーネントライブラリです。
セットアップ:
npm install @headlessui/react使用例:
'use client';
import { Dialog } from '@headlessui/react';import { XMarkIcon } from '@heroicons/react/24/outline';
export default function AccessibleModal({ isOpen, onClose, title, description, children,}: { isOpen: boolean; onClose: () => void; title: string; description?: string; children: React.ReactNode;}) { return ( <Dialog open={isOpen} onClose={onClose} className="relative z-50"> {/* オーバーレイ */} <div className="fixed inset-0 bg-black/50" aria-hidden="true" />
{/* モーダルコンテンツ */} <div className="fixed inset-0 flex items-center justify-center p-4"> <Dialog.Panel className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full"> <div className="flex items-center justify-between mb-4"> <Dialog.Title className="text-xl font-bold"> {title} </Dialog.Title> <button onClick={onClose} className="text-gray-400 hover:text-gray-600" aria-label="閉じる" > <XMarkIcon className="h-6 w-6" /> </button> </div> {description && ( <Dialog.Description className="text-gray-600 mb-4"> {description} </Dialog.Description> )} {children} </Dialog.Panel> </div> </Dialog> );}
// 使用例<AccessibleModal isOpen={isOpen} onClose={() => setIsOpen(false)} title="モーダルのタイトル" description="モーダルの説明"> <p>モーダルのコンテンツ</p></AccessibleModal>メリット:
- ✅ Tailwind CSSとの親和性が高い
- ✅ フォーカストラップが自動的に実装される
- ✅ Escキーで閉じる機能が自動的に実装される
- ✅ アクセシビリティのベストプラクティスが既に実装されている
3. shadcn/ui(Radix UIベース)
shadcn/uiは、Radix UIを基盤とした再利用可能なコンポーネント集です。
セットアップ:
npx shadcn-ui@latest add dialog使用例:
// components/ui/dialog.tsx(自動生成される)// 既にアクセシビリティが実装されている
// 使用例import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog"
export default function MyModal() { return ( <Dialog> <DialogTrigger>モーダルを開く</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>モーダルのタイトル</DialogTitle> <DialogDescription>モーダルの説明</DialogDescription> </DialogHeader> <p>モーダルのコンテンツ</p> </DialogContent> </Dialog> )}比較: 自前実装 vs Headless UIライブラリ
Section titled “比較: 自前実装 vs Headless UIライブラリ”| 項目 | 自前実装 | Radix UI / Headless UI |
|---|---|---|
| フォーカストラップ | ❌ 実装が複雑でバグが発生しやすい | ✅ 自動的に実装される |
| Escキーで閉じる | ❌ 手動で実装が必要 | ✅ 自動的に実装される |
| ARIA属性 | ❌ 手動で設定が必要 | ✅ 自動的に設定される |
| スクリーンリーダー対応 | ❌ 実装が不十分になりがち | ✅ 検証済み |
| 保守性 | ❌ 複雑で保守が困難 | ✅ ライブラリが保守 |
| テスト | ❌ すべてのケースをテストする必要がある | ✅ 既にテスト済み |
💡 シニアのアドバイス
Section titled “💡 シニアのアドバイス”「車輪の再発明をせず、枯れたライブラリを使う」のが真のアクセシビリティ設計です。
フォーカストラップ、キーボード操作、ARIA属性などを完璧に実装するのは極めて困難です。実装の不備は、アクセシビリティを破壊する可能性があります。
推奨されるライブラリ:
- Radix UI: 最も包括的で、アクセシビリティに強い
- Headless UI: Tailwind CSSとの親和性が高い
- shadcn/ui: Radix UIベースで、カスタマイズ性が高い
実務での推奨事項:
- ✅ モーダル、ドロップダウン、ツールチップなどは、Headless UIライブラリを使用
- ✅ 自前で実装する場合は、既存のライブラリを参考にする
- ❌ アクセシビリティが重要なコンポーネントは、自前で実装しない
色のコントラスト
Section titled “色のコントラスト”// 十分なコントラスト比を確保export default function Button() { return ( <button style={{ backgroundColor: '#0070f3', // 十分なコントラスト比 color: '#ffffff', }} > Click me </button> );}
// 悪い例: コントラスト比が低いexport default function Button() { return ( <button style={{ backgroundColor: '#cccccc', // コントラスト比が低い color: '#ffffff', }} > Click me </button> );}画像のalt属性
Section titled “画像のalt属性”// 装飾的な画像export default function DecorativeImage() { return ( <img src="/decorative-image.jpg" alt="" // 装飾的な画像は空のalt属性 role="presentation" /> );}
// 意味のある画像export default function MeaningfulImage() { return ( <img src="/product-image.jpg" alt="赤いTシャツ、サイズM、価格3,000円" /> );}フォームのアクセシビリティ
Section titled “フォームのアクセシビリティ”// 適切なラベルとエラーメッセージexport default function ContactForm() { const [errors, setErrors] = useState<Record<string, string>>({});
return ( <form> <div> <label htmlFor="name">名前</label> <input id="name" type="text" aria-required="true" aria-invalid={!!errors.name} aria-describedby={errors.name ? 'name-error' : undefined} /> {errors.name && ( <span id="name-error" role="alert"> {errors.name} </span> )} </div> </form> );}実践的な例: アクセシブルなコンポーネント
Section titled “実践的な例: アクセシブルなコンポーネント”'use client';
import { ButtonHTMLAttributes, forwardRef } from 'react';
interface AccessibleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { loading?: boolean; variant?: 'primary' | 'secondary';}
export const AccessibleButton = forwardRef< HTMLButtonElement, AccessibleButtonProps>(({ loading, variant = 'primary', children, ...props }, ref) => { return ( <button ref={ref} disabled={loading || props.disabled} aria-busy={loading} aria-disabled={loading || props.disabled} className={`button button-${variant}`} {...props} > {loading ? ( <> <span className="sr-only">読み込み中</span> <LoadingSpinner aria-hidden="true" /> </> ) : ( children )} </button> );});
AccessibleButton.displayName = 'AccessibleButton';テストツール
Section titled “テストツール”eslint-plugin-jsx-a11y
Section titled “eslint-plugin-jsx-a11y”npm install --save-dev eslint-plugin-jsx-a11y{ "extends": ["plugin:jsx-a11y/recommended"]}@axe-core/react
Section titled “@axe-core/react”npm install --save-dev @axe-core/reactif (process.env.NODE_ENV !== 'production') { const React = require('react'); const ReactDOM = require('react-dom'); const axe = require('@axe-core/react'); axe(React, ReactDOM, 1000);}Next.jsでアクセシビリティを実装するポイント:
- Route Announcer: Next.js標準のルートアナウンサーを活用(適切なMetadata設計が重要)
- セマンティックHTML: 適切なHTML要素の使用
- ARIA属性: スクリーンリーダーへの情報提供(
aria-liveは必要な箇所のみに限定) - キーボード操作: すべての機能をキーボードで操作可能
- Headless UIライブラリ: Radix UIやHeadless UIなどの検証済みライブラリを使用(自前実装は避ける)
- 色のコントラスト: WCAG準拠のコントラスト比
- フォーム: 適切なラベルとエラーメッセージ
- テスト: アクセシビリティテストツールの使用
🚨 重要な注意点:
- Route Announcer:
layout.tsxで適切なMetadata設計を行い、動的なタイトル更新を正しく実装する - aria-live: むやみに使用せず、「フォーム送信の成功/失敗」や「検索結果の件数変化」など、本当に必要な箇所に絞る
- フォーカストラップ: 自前で実装せず、Radix UIやHeadless UIなどの検証済みライブラリを使用する
アクセシビリティは、すべてのユーザーがアプリケーションを利用できるようにするための重要な要素です。適切に実装することで、より包括的で使いやすいアプリケーションを構築できます。