ARIA実践ガイド:最小ARIAで堅牢なUIをつくる——APG準拠のコンポーネント設計と落とし穴
概要サマリー(先に要点)
- ネイティブHTML最優先+最小ARIAが、スクリーンリーダー・キーボード・音声操作で“壊れにくい”最短ルートです。
- **名前・役割・値(Name/Role/Value)**を正しく露出することが、支援技術互換性のコア原則。
- APG(ARIA Authoring Practices)準拠の挙動を守る:タブ=矢印移動、ダイアログ=フォーカストラップ、メニューはWebサイトでは基本不要など。
- やってはいけないARIA(
aria-hiddenの誤用、role="menu"乱用、ポリライトロールの付けすぎ)を避ける。- 実務でそのまま使えるコード雛形・テスト観点・チェックリストを収録(タブ、アコーディオン、ダイアログ、トグルボタン、コンボボックス)。
対象読者(具体):フロントエンドエンジニア、UI/UXデザイナー、デザインシステム担当、QA/アクセシビリティ担当、PM
アクセシビリティレベル:WCAG 2.1 AA 準拠を基準(可能箇所はAAAも視野)。
誰に役立つ? 画面リーダー利用者、キーボード操作中心の方、音声入力ユーザー、認知・学習特性の多様な方、視力・色覚に多様性のある方、モバイル利用者全般。
1. まず「最小ARIA」という考え方:HTMLが“できること”を最大限に
ARIAは魔法ではなく最後の調味料です。<button>, <a href>, <label>, <fieldset>, <table> など、HTMLにはすでに意味とキーボード挙動が備わっています。
- 原則
- 先にネイティブ要素で要件を満たす。
- 足りない時だけ最小限のARIAで補う。
- 挙動(キーボード操作・フォーカス制御)もセットで実装。
- ありがちな誤解:「何でもARIAロールを付ければ良い」→ ❌。不必要なロールは、逆に支援技術の解釈を壊しますの。
やってはいけない代表例
- 可視の操作要素に
aria-hidden="true"(ユーザーから消えてしまう)。 - ページナビに
role="menu"(アプリ内メニュー限定想定。Webのグロナビはul/li+a/buttonで十分)。 - ただの飾りアイコンに長文
aria-label(ノイズの原因。装飾はaria-hidden="true"で沈黙)。
2. Name / Role / Value を揃える:支援技術に伝える三点セット
2.1 名前(Accessible Name)
- 可視テキスト=名前が基本。
<button>保存</button>の「保存」が名前になります。 - アイコンボタンは、可視テキストか
aria-labelを付与。可視の文言と矛盾しないこと(WCAG 2.5.3)。
<button aria-label="検索">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
2.2 役割(Role)
- ネイティブ要素で自動付与される役割を上書きしない。
- 必要時のみ
role="tab",role="dialog",role="switch"などを最小限で。
2.3 値(State/Property)
- 状態はARIA属性で露出:
aria-expanded(開閉)、aria-selected(選択)、aria-pressed(トグル)、aria-current(現在ページ)など。
<a href="/pricing" aria-current="page">価格</a>
<button aria-pressed="true">お気に入り</button>
3. APG準拠のキーパターン:ロービングTab/activedescendant/ラベル連係
3.1 Roving tabindex(タブ・ツールバー等)
- 選択中の項目だけ
tabindex="0"、他は-1。 - 矢印キーで選択移動、
Enter/Spaceで決定。
<div role="tablist" aria-label="設定">
<button role="tab" aria-selected="true" tabindex="0" id="t1" aria-controls="p1">一般</button>
<button role="tab" aria-selected="false" tabindex="-1" id="t2" aria-controls="p2">通知</button>
</div>
<section id="p1" role="tabpanel" aria-labelledby="t1">…</section>
<section id="p2" role="tabpanel" aria-labelledby="t2" hidden>…</section>
3.2 aria-activedescendant(オートコンプリート/仮想リスト)
- 入力欄にフォーカスを置いたまま、候補の活性項目IDを伝える方式。
<input id="city" role="combobox" aria-expanded="true" aria-controls="list" aria-activedescendant="opt-2">
<ul id="list" role="listbox">
<li role="option" id="opt-1">Tokyo</li>
<li role="option" id="opt-2" aria-selected="true">Toronto</li>
</ul>
3.3 ラベル連係(aria-labelledby/aria-describedby)
- 見出しや説明とコンポーネントをしっかり紐づける。
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title" aria-describedby="dlg-desc">
<h2 id="dlg-title">利用規約</h2>
<p id="dlg-desc">要点の抜粋と同意のお願い</p>
</div>
4. コンポーネント別:最小ARIAレシピ(コピペOK)
4.1 アコーディオン(Disclosure)
- “何が開閉のトリガか”を**
buttonで明示**。 - 開閉状態は
aria-expanded。対象パネルはaria-controls+hidden。
<h3>
<button aria-expanded="false" aria-controls="faq1" id="q1">配送について</button>
</h3>
<div id="faq1" role="region" aria-labelledby="q1" hidden>
<p>通常2〜3営業日でお届けします。</p>
</div>
<script>
const btn = document.getElementById('q1');
const panel = document.getElementById('faq1');
btn.addEventListener('click', ()=>{
const open = btn.getAttribute('aria-expanded')==='true';
btn.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
});
</script>
4.2 タブ(Tab / Tabpanel)
- Roving
tabindex+aria-selectedで選択状態を伝える。 - 非表示パネルは
hiddenでTab順から除外。
const tabs=[...document.querySelectorAll('[role="tab"]')];
function activate(i, focus=true){
tabs.forEach((t,idx)=>{
const selected=idx===i;
t.setAttribute('aria-selected', selected);
t.tabIndex=selected?0:-1;
document.getElementById(t.getAttribute('aria-controls')).hidden=!selected;
});
if(focus) tabs[i].focus();
}
tabs.forEach((t,i)=>{
t.addEventListener('click', ()=>activate(i,false));
t.addEventListener('keydown', e=>{
const dir = (e.key==='ArrowRight')- (e.key==='ArrowLeft');
if(dir){ e.preventDefault(); activate((i+dir+tabs.length)%tabs.length); }
if(e.key==='Home'){ e.preventDefault(); activate(0); }
if(e.key==='End'){ e.preventDefault(); activate(tabs.length-1); }
});
});
4.3 ダイアログ(Modal)
role="dialog"+aria-modal="true"、タイトル連係(aria-labelledby)。- フォーカストラップ、
Escで閉じる、閉じたらトリガに復帰。
<button id="open">利用規約</button>
<div id="dlg" role="dialog" aria-modal="true" aria-labelledby="ttl" hidden>
<h2 id="ttl">利用規約</h2>
<p>…要点…</p>
<button id="agree">同意する</button>
<button id="close">閉じる</button>
</div>
<script>
const open=document.getElementById('open'), dlg=document.getElementById('dlg'), close=document.getElementById('close');
let last;
function trap(e){
if(e.key==='Escape'){ hide(); return; }
if(e.key!=='Tab')return;
const f=[...dlg.querySelectorAll('a,button,[tabindex="0"]')]; if(!f.length)return;
const first=f[0], lastF=f[f.length-1];
if(e.shiftKey && document.activeElement===first){e.preventDefault(); lastF.focus();}
else if(!e.shiftKey && document.activeElement===lastF){e.preventDefault(); first.focus();}
}
function show(){ last=document.activeElement; dlg.hidden=false; dlg.querySelector('button')?.focus(); document.addEventListener('keydown',trap); }
function hide(){ dlg.hidden=true; document.removeEventListener('keydown',trap); last?.focus(); }
open.addEventListener('click',show); close.addEventListener('click',hide);
</script>
4.4 トグルボタン(Pressed / Switch)
- 押下状態は
aria-pressed(ボタン)またはrole="switch"+aria-checked。
<button class="fav" aria-pressed="false">お気に入り</button>
<script>
const b=document.querySelector('.fav');
b.addEventListener('click',()=> b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed')!=='true')));
</script>
4.5 コンボボックス(検索サジェストの最小形)
role="combobox"+aria-controls+aria-expanded+aria-activedescendant。- 候補は
role="listbox"/role="option"。
<label for="kw">キーワード</label>
<input id="kw" role="combobox" aria-expanded="false" aria-controls="kw-list" autocomplete="off">
<ul id="kw-list" role="listbox" hidden></ul>
<script>
const inp=kw, list=kw-list;
const data=['JavaScript','Java','Ruby','Rust','Python'];
inp.addEventListener('input', ()=>{
const q=inp.value.toLowerCase();
const items=data.filter(x=>x.toLowerCase().startsWith(q)).slice(0,5);
list.innerHTML=items.map((t,i)=>`<li role="option" id="opt-${i}">${t}</li>`).join('');
const has=items.length>0; inp.setAttribute('aria-expanded', String(has)); list.hidden=!has;
if(has) inp.setAttribute('aria-activedescendant','opt-0'); else inp.removeAttribute('aria-activedescendant');
});
list.addEventListener('click', e=>{
if(e.target.role!=='option') return;
inp.value=e.target.textContent; list.hidden=true; inp.setAttribute('aria-expanded','false');
});
</script>
5. “やってはいけないARIA”とその理由(アンチパターン早見表)
| アンチパターン | 何が問題? | 正しい代替 |
|---|---|---|
見出しの見た目に<div role="heading" aria-level="2">乱用 |
HTMLの意味階層が崩れる | <h2>などネイティブ見出し+CSSでスタイル |
グローバルナビにrole="menu" |
Webナビとアプリメニューは別物。矢印移動等が期待され混乱 | nav+ul/li+a/button(必要ならDisclosure) |
可視ボタンにaria-hidden="true" |
支援技術から消える=操作不能 | 操作要素にaria-hiddenは付けない |
すべての<svg>に長文aria-label |
情報洪水。装飾まで読み上げてしまう | 装飾はaria-hidden="true"、情報は<title>や親のラベルで |
tabindexの正値(例:tabindex="99") |
フォーカス順が破綻し保守不能 | 0か-1で。DOM順で設計 |
role="alert"多用で毎秒読み上げ |
騒音。重要でない更新まで割り込む | 状態通知はrole="status"(控えめ)を基本に |
6. アクセシブルネームの落とし穴:可視テキストと“一致”していますか?
- Label in Name(2.5.3):音声コマンドは可視テキストで操作します。「検索」と読んだのに実体のアクセシブルネームが「さがす」だと反応しないことが。
<!-- 可視:「検索」/名前にも「検索」 -->
<button aria-label="検索">検索</button>
- 複合ラベル:アイコン+テキストではテキストが先に来ると、読み上げが自然です。
- 動的文言:ローディング中の「保存→保存中…」は、
aria-live="polite"かrole="status"の領域で短く伝えます。
7. スクリーンリーダー観点:地図(見出し・ランドマーク)+動的更新(ライブリージョン)
- ランドマーク:
header/nav/main/aside/footerを適切に。複数あるnavはaria-label="フッターナビ"のように区別。 - 見出し:
h1は1つ、セクションに合わせて階層化。 - ライブリージョン:控えめに。フォームエラーは
role="alert"、保存完了はrole="status"。
<div id="msg" role="status" aria-atomic="true" class="sr-only"></div>
<script> msg.textContent='下書きを保存しました'; </script>
8. デザインシステムへの落とし込み:仕様書に“名前・役割・値・キー操作”を記述
各コンポーネントのドキュメントに必ず以下を入れます。
- 名前(アクセシブルネーム):可視テキスト/
aria-label/aria-labelledby。 - 役割(role):ネイティブ or 付与。
- 状態(値):
aria-expanded/aria-selected/aria-pressed/aria-currentなど。 - キー操作:Tab/Shift+Tab、矢印、Enter/Space、Esc、Home/End。
- フォーカス管理:初期フォーカス、トラップ、復帰先。
- コントラスト:フォーカスリング・アイコン・境界の非テキスト3:1以上。
9. 手動テストの定型(5分スモーク)
- Tab巡回:ヘッダー→ナビ→本文→フッターに論理順で移動。
- フォーカスが見える:
:focus-visibleで輪郭が十分目立つ。 - タブ/アコーディオン:矢印移動・
aria-selected/aria-expandedが更新。 - ダイアログ:開く→タイトルへフォーカス→トラップ→Esc閉じ→トリガ復帰。
- コンボボックス:候補に矢印移動、
aria-activedescendantが更新、Enterで確定。 - SRチェック(NVDA/VoiceOver/TalkBack):見出し一覧・ランドマーク移動・フォームのラベル読上げを短時間で確認。
10. ケーススタディ:<div>製メニューを救出する
Before
<div class="menu">
<div class="item" onclick="go('/pricing')">価格</div>
<div class="item" onclick="go('/docs')">ドキュメント</div>
</div>
- 役割不明、キーボード不可、SRに読み上げられない。
After(最小ARIA+ネイティブ優先)
<nav aria-label="主なナビゲーション">
<ul>
<li><a href="/pricing">価格</a></li>
<li><a href="/docs">ドキュメント</a></li>
</ul>
</nav>
- 勝手に正しくなる:リンクの役割、Tab移動、SR読み上げが自動で整います。これが最小ARIAの威力ですわ。
11. 誰がどう得をする?(具体的インパクト)
- フロントエンドエンジニア:イベント配線や自前ロールのバグが激減、回帰に強い。
- UI/UXデザイナー:キー操作仕様とラベル原則が明文化され、設計の一貫性が増す。
- QA/アクセシビリティ担当:テスト観点が“Name/Role/Value+APG準拠”で標準化、判定のブレが減少。
- PM/ビジネス:法適合の土台が固まり、ブランド信頼と運用コストの双方を改善。
- ユーザー(支援技術利用・キーボード・音声):迷わず操作でき、疲労と誤操作が減る。認知負荷も軽減。
12. クイックチェックリスト(貼り付け用)
- [ ] ネイティブ要素で実現し、足りない部分のみARIAを最小で補強。
- [ ] アクティブ要素に
aria-hiddenを付けていない。 - [ ] Name/Role/Valueが常に露出(ラベル=可視テキスト/適切なロール/状態属性)。
- [ ] タブ・ツールバーはRoving
tabindex、ダイアログは**aria-modal+トラップ+復帰**。 - [ ] コンボボックスは
aria-activedescendantか実フォーカス移動のいずれかで一貫。 - [ ] グロナビに
role="menu"を使っていない。 - [ ] フォーカスリングは常に見え、非テキスト3:1を満たす。
- [ ] SRで見出し・ランドマーク・フォームの読み上げ地図が機能。
- [ ] ライブ通知は**
role="status"基本**、緊急時のみrole="alert"。
14. まとめ:ARIAは“最後のひとさじ”。土台はHTMLと設計です
- ネイティブ要素最優先+最小ARIAで、安定・互換・保守性を同時に手に入れる。
- Name/Role/Valueを常に揃え、状態変化を
aria-*で確実に伝える。 - APG準拠のキー操作(タブ=矢印、ダイアログ=トラップ、メニューの適用範囲)を守る。
- アンチパターンを避ける(
aria-hiddenの誤用、role="menu"乱用、正値tabindex)。 - デザインシステムに名前・役割・値・キー操作を明文化し、テストの型(5分スモーク)を運用へ。
ARIAは、最後に香りを整える仕上げのひとさじ。土台のHTMLと情報設計が整っているほど、少ないARIAで、より多くの人に届く体験が実現します。あなたのUIが、今日からもっと“静かに正しく伝わる”よう、わたしも心を込めて応援していますわ。
