通知・ダイアログ

モーダルダイアログ

解説あり

Dialog (Modal)

背面を操作不能にして前面に出すダイアログ。フォーカストラップと Esc 閉じが要。

仕様が更新されました

モーダルダイアログとは?

モーダルダイアログは、画面の上に重なって開き、閉じるまで背面の操作をブロックする小さなウィンドウです。 設定パネル、フォーム、画像の拡大表示などに使われます。

モーダルで一番むずかしいのはフォーカスの管理です。 幸い、最近のブラウザのネイティブ <dialog> 要素をshowModal() で開けば、その大変な部分が標準で手に入ります

なぜアクセシビリティが大事なの?

モーダルが正しくないと、こんな問題が起きます。

ネイティブ <dialog> + showModal() は、これらすべて (フォーカストラップ・Escで閉じる・背面を inert 化・フォーカスの復帰)を 自動でやってくれます。

ライブデモ(推奨実装)

下の「設定を開く」を押すとダイアログが開きます。キーボードだけで操作して、Tab が外に漏れないこと・Esc で閉じることを確かめてください。

ネイティブ <dialog> + showModal() のモーダル

表示設定

テーマやフォントサイズをここで変更できます。(デモ用の中身です)

試してみよう:Enter で開く → Tab を連打してもフォーカスはダイアログ内をループ → Esc で閉じる → フォーカスが「設定を開く」ボタンに戻る。

ポイント

開いた瞬間、フォーカスはダイアログ内(最初のボタンや autofocus を付けた要素)へ移ります。 閉じると開いたトリガーへ自動で戻るので、利用者は元の文脈を失いません。

キーボード操作

キー動作必須/任意
Escダイアログを閉じる(showModal() なら標準で対応)必須
Tab / Shift + Tabフォーカスがダイアログ内だけを循環する(外に漏れない)必須
Enter / Spaceフォーカス中のボタンを実行必須

補足

上のキー操作は <dialog>.showModal() を使えばすべて自動です。 自前の div モーダルだと、これらを一つひとつ JavaScript で実装しなければなりません。

必要な WAI-ARIA / ロール

付ける場所属性 / ロール意味
ダイアログ本体<dialog> 要素暗黙の role="dialog" を持つ。showModal() でモーダルとして開く。
ダイアログ本体aria-labelledby="見出しのid" または aria-labelダイアログに名前を付ける(中の見出しを指すのが定番)。
ダイアログ本体aria-describedby(任意)補足の説明文を関連付ける。
背面のコンテンツ(自動)inert 相当showModal() なら背面が自動で操作不可・読み上げ対象外になる。手動なら inert を付ける。

実装:推奨パターン(Good)

良い例 / 推奨

ネイティブ <dialog>showModal() で開く。フォーカス管理・Esc・背面 inert が標準で付いてくる。

マークアップ:

<button type="button" id="open">設定を開く</button>

<dialog id="dialog">
  <h2>表示設定</h2>
  <p>テーマやフォントサイズをここで変更できます。</p>
  <button type="button" id="close">閉じる</button>
</dialog>

開閉のスクリプト(たったこれだけ):

const dialog = document.getElementById('dialog');
const openBtn = document.getElementById('open');
const closeBtn = document.getElementById('close');

// showModal() だけで、フォーカストラップ・Esc で閉じる・
// 背面を操作不可(inert)にする、が標準で手に入る。
openBtn.addEventListener('click', () => dialog.showModal());
closeBtn.addEventListener('click', () => dialog.close());

// 閉じたあとは、開いたトリガーへ自動でフォーカスが戻る。

補足

ダイアログに名前を付けるため、<dialog aria-labelledby="title"> のように 中の見出しの id を指しておくと、スクリーンリーダーが 「表示設定, ダイアログ」と読み上げてくれます。

アンチパターン(Bad)

下は <div> を重ねただけの自作モーダルです。マウスでは開閉できますが、開いたまま Tab を押すと フォーカスが背面に抜け、Esc でも閉じません。

div オーバーレイの壊れたモーダル

試してみよう:開いた状態で Tab を連打 → 背面の「設定を開く」やページ内リンクにフォーカスが移ってしまう。Esc を押しても閉じない。× はキーボードでは押せない。

<!-- ❌ div オーバーレイの自作モーダル -->
<button type="button" id="open">設定を開く</button>

<div id="overlay" class="overlay" style="display:none">
  <div class="modal">
    <h2>表示設定</h2>
    <p>テーマやフォントサイズをここで変更できます。</p>
    <!-- span なのでキーボードで閉じられない -->
    <span class="x" onclick="hide()">×</span>
  </div>
</div>

悪い例 / 避ける

この実装の問題点:

  • Tab が背面に漏れる — フォーカストラップが無く、見えない要素に移動できてしまう。
  • Esc で閉じない — キー処理を自分で書いていない。
  • 背面が inert でない — スクリーンリーダーで背面まで読めてしまう。
  • 閉じるのが <span> — フォーカスできず、キーボードでは閉じられない。
  • フォーカスの移動/復帰が無い — 開いてもダイアログに入らず、閉じても元へ戻らない。

ポイント

どうしても <div> で作るなら、フォーカストラップ・Esc・背面 inert・ フォーカス復帰・role="dialog" / aria-modal="true"すべて自前で実装する必要があります。<dialog> なら、その大半が無料です。

実装チェックリスト

現場の実例 — APG と実装の違い

aria-modal="true" だけに頼らず、背面要素へ aria-hidden / inert を付与する

APG では
モーダルダイアログには role="dialog" と aria-modal="true" を付ける。aria-modal は ARIA 1.1 で、従来の「背面要素すべてに aria-hidden を付ける」手法に代わるものとして導入された。
現場では
floating-ui の FloatingFocusManager や React Aria(ariaHideOutside)は、モーダル表示中に外側の DOM ツリーを走査して aria-hidden(可能なら inert)を付与する。Base UI の Dialog / AlertDialog もこの仕組みの上に載っており、aria-modal 属性そのものを付けない実装が多い。
なぜ
aria-modal のスクリーンリーダー対応が実測で信頼できなかったのが発端。WebKit のバグ(2017 年報告、長年未解決)では aria-modal="true" を付けると VoiceOver がダイアログ内の静的テキストを読み飛ばし、Bootstrap や react-modal の issue では iOS VoiceOver がスワイプ操作でフォーカストラップを無視して背面へ抜ける事例が報告された。NVDA では aria-modal だけで機能する一方 VoiceOver 系では信頼できない、という AT 間の差を「確実に効く背面 aria-hidden の明示付与」で埋めるのが現場標準になった。機能面(背面を支援技術から隠す・フォーカスを閉じ込める・閉じたら復帰する)は APG の意図を満たしている。

採用:floating-uiReact AriaBase UI

出典:WebKit Bug 174667 — aria-modal makes VoiceOver skip content(新しいタブで開きます)Floating UI — FloatingFocusManager(新しいタブで開きます)react-modal#611 — VoiceOver fails with aria-modal(新しいタブで開きます)

ネイティブ <dialog> は「使うな」から「条件付きで使ってよい」へ転換した

APG では
APG の Dialog パターンは role="dialog" + JavaScript のカスタムウィジェットとして書かれており、ネイティブ <dialog> 要素の採否には直接踏み込んでいない。
現場では
アクセシビリティ実務家の定番記事だった Scott O'Hara の "Having an open dialog"(2019)は長年「ネイティブ dialog は避けよ」の根拠だったが、2023 年 1 月に「条件付きで使ってよい」へ改訂された。一方 a11y-dialog のような定番ライブラリは、ブラウザ間のスタイル差や open イベントの不在などを理由に、今もネイティブ dialog を採用していない。
なぜ
2019 年時点では Chromium のみの対応で、フォーカス配置の不備・スクリーンリーダー間の挙動不一致・閉じた後にフォーカスが呼び出し元へ戻らない、といった問題が実測されていた。2022 年に Safari 15.4 と Firefox 98 が対応して全主要ブラウザが実装したことで、著者は「利用者のブラウザ統計が十分に新しくなるまでは堅牢なカスタム実装を推奨」という条件付き容認へ立場を更新した。意見ではなく「主要ブラウザの実装状況」という計測可能な基準で推奨が変わった好例で、なお showModal() を使えば aria-modal 相当の挙動が自動で付く点も現場の判断材料になっている。

採用:Scott O'Haraa11y-dialog

出典:Scott O'Hara — Having an open dialog(新しいタブで開きます)a11y-dialog — On the dialog element(新しいタブで開きます)

フォーカストラップは keydown 監視ではなく「見えない番兵要素」方式に収束した

APG では
APG 本文は「Tab で次の要素へ、最後の要素では最初へ戻す」という挙動要件のみを定め、実装方式(番兵要素か keydown ハンドリングか)は開発者に委ねている。
現場では
APG 自身の参照実装(dialog.js)はダイアログの直前・直後に tabindex="0" の div を動的に挿入し、そこへフォーカスが来たら端へ戻す方式。Radix UI(react-focus-guards)も文書端に隠し span を 2 つ挿入し、floating-ui の FloatingFocusManager も guards オプション(デフォルト有効)で同様の隠しガード要素を生成する。業界が独立に同じ方式へ収束している。
なぜ
Tab キーの keydown を傍受するだけの方式では、マウスクリックやスクリーンリーダーの仮想カーソル操作など「Tab 以外の手段」でフォーカスがダイアログ外へ抜けるケースを検知できない。フォーカスイベントベースの番兵方式なら移動手段を問わず逸脱を捕捉できる。これは APG との対立ではなく、APG が沈黙している実装詳細について参照実装と主要ライブラリが同じ結論に達した「収束」の実例で、フォーカストラップを自作するときに選ぶべき方式の答えでもある。

採用:W3C APG reference implementationRadix UIfloating-ui

出典:w3c/aria-practices#545 — Documenting the sentinel technique(新しいタブで開きます)W3C APG — Dialog (Modal) Pattern(新しいタブで開きます)Floating UI — FloatingFocusManager(guards)(新しいタブで開きます)


原文(英語):Dialog (Modal) Pattern — W3C APG(新しいタブで開きます)