woman on black folding wheelchair
Photo by Judita Mikalkevičė on Pexels.com
目次

ARIA実践ガイド:最小ARIAで堅牢なUIをつくる——APG準拠のコンポーネント設計と落とし穴

概要サマリー(先に要点)

  • ネイティブHTML最優先+最小ARIAが、スクリーンリーダー・キーボード・音声操作で“壊れにくい”最短ルートです。
  • **名前・役割・値(Name/Role/Value)**を正しく露出することが、支援技術互換性のコア原則。
  • APG(ARIA Authoring Practices)準拠の挙動を守る:タブ=矢印移動、ダイアログ=フォーカストラップ、メニューはWebサイトでは基本不要など。
  • やってはいけないARIAaria-hiddenの誤用、role="menu"乱用、ポリライトロールの付けすぎ)を避ける。
  • 実務でそのまま使えるコード雛形・テスト観点・チェックリストを収録(タブ、アコーディオン、ダイアログ、トグルボタン、コンボボックス)。

対象読者(具体):フロントエンドエンジニア、UI/UXデザイナー、デザインシステム担当、QA/アクセシビリティ担当、PM
アクセシビリティレベルWCAG 2.1 AA 準拠を基準(可能箇所はAAAも視野)。
誰に役立つ? 画面リーダー利用者、キーボード操作中心の方、音声入力ユーザー、認知・学習特性の多様な方、視力・色覚に多様性のある方、モバイル利用者全般。


1. まず「最小ARIA」という考え方:HTMLが“できること”を最大限に

ARIAは魔法ではなく最後の調味料です。<button>, <a href>, <label>, <fieldset>, <table> など、HTMLにはすでに意味とキーボード挙動が備わっています。

  • 原則
    1. 先にネイティブ要素で要件を満たす。
    2. 足りない時だけ最小限のARIAで補う。
    3. 挙動(キーボード操作・フォーカス制御)もセットで実装。
  • ありがちな誤解:「何でもARIAロールを付ければ良い」→ ❌。不必要なロールは、逆に支援技術の解釈を壊しますの。

やってはいけない代表例

  • 可視の操作要素に aria-hidden="true"(ユーザーから消えてしまう)。
  • ページナビに role="menu"(アプリ内メニュー限定想定。Webのグロナビはul/lia/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-controlshidden
<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 tabindexaria-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-controlsaria-expandedaria-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ナビとアプリメニューは別物。矢印移動等が期待され混乱 navul/lia/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 を適切に。複数あるnavaria-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-labelaria-labelledby
  • 役割(role):ネイティブ or 付与。
  • 状態(値)aria-expanded/aria-selected/aria-pressed/aria-currentなど。
  • キー操作:Tab/Shift+Tab、矢印、Enter/Space、Esc、Home/End。
  • フォーカス管理:初期フォーカス、トラップ、復帰先。
  • コントラスト:フォーカスリング・アイコン・境界の非テキスト3:1以上。

9. 手動テストの定型(5分スモーク)

  1. Tab巡回:ヘッダー→ナビ→本文→フッターに論理順で移動。
  2. フォーカスが見える:focus-visibleで輪郭が十分目立つ。
  3. タブ/アコーディオン:矢印移動・aria-selected/aria-expandedが更新。
  4. ダイアログ:開く→タイトルへフォーカス→トラップ→Esc閉じ→トリガ復帰
  5. コンボボックス:候補に矢印移動、aria-activedescendantが更新、Enterで確定。
  6. 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と設計です

  1. ネイティブ要素最優先+最小ARIAで、安定・互換・保守性を同時に手に入れる。
  2. Name/Role/Valueを常に揃え、状態変化を aria-* で確実に伝える。
  3. APG準拠のキー操作(タブ=矢印、ダイアログ=トラップ、メニューの適用範囲)を守る。
  4. アンチパターンを避けるaria-hiddenの誤用、role="menu"乱用、正値tabindex)。
  5. デザインシステムに名前・役割・値・キー操作を明文化し、テストの型(5分スモーク)を運用へ。

ARIAは、最後に香りを整える仕上げのひとさじ。土台のHTMLと情報設計が整っているほど、少ないARIAで、より多くの人に届く体験が実現します。あなたのUIが、今日からもっと“静かに正しく伝わる”よう、わたしも心を込めて応援していますわ。


投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)