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が、今日からもっと“静かに正しく伝わる”よう、わたしも心を込めて応援していますわ。