結論

プロは「雑なモーダル」を W3C APG の3本柱——フォーカス管理(開いたら内部へ、閉じたら起動元へ)、正しいセマンティクス(role="dialog" + aria-modal="true" + ラベル)、背景の無効化と暗転(inert + scrim)——で解決する。2022年以降はネイティブ <dialog> + showModal() を第一選択にすれば、top layer 描画・背景の inert 化・暗黙の aria-modal・初期フォーカス移動・Esc で閉じる・::backdrop がブラウザから無料で得られ、手書きのフォーカストラップ JS は原則不要になる。最大の差は「閉じ方」——背景クリックは ARIA 要件ではない便利機能にすぎず、Esc か可視のクローズボタンによるキーボード閉じが必須だ。

01 — ネイティブ <dialog> + showModal() を第一選択にする

<div> をオーバーレイで重ねただけの手製モーダルは、フォーカス管理も inert も Esc も自前 JS が必要で、どれか1つ欠けた瞬間に「雑」になる。showModal() で開けば top layer 描画・背景の inert 化・暗黙の aria-modal="true"・初期フォーカス移動・Esc 閉じ・::backdrop が全て自動で付く。show()open 属性は非モーダル(aria-modal="false"、Esc 自動クローズなし)なので、モーダル挙動が要るときは必ず showModal() を使う。

ページ本文 ページ本文 ページ本文 ページ本文 ページ本文 ページ本文 ページ本文 ページ本文
確認

div を重ねただけ

inert / Esc 無し
✗ 安っぽい例:暗転なし・背景にフォーカスが抜ける・Esc で閉じない
ページ本文 ページ本文 ページ本文 ページ本文 ページ本文 ページ本文 ページ本文 ページ本文
確認

showModal() で開く

inert / Esc 自動
✓ プロの例:top layer・背景 inert・::backdrop・Esc が無料で付く

primary<dialog> HTML dialog element — MDN

primaryHTMLDialogElement: showModal() method — MDN

02 — 正しいセマンティクスを付ける(role + aria-modal + ラベル)

aria-modal="true" はアクセシビリティツリーを変えるだけで、フォーカス管理やモーダル挙動そのものは別途必要だ。コンテナに role="dialog"(確認・警告系は role="alertdialog")と aria-modal="true" を付け、可視タイトルを aria-labelledby で参照するか aria-label を付ける(どちらか必須)。これがあると見出しがスクリーンリーダーに読まれ、背景は aria-hidden を付けなくても無視される。<dialog> + showModal() なら暗黙付与されるが、手製なら明示が必須だ。

▸ <div class="overlay">
 ▸ <div class="modal">
   <div> 削除しますか?
   <button> 削除
⚠ role 無し → グループとして
 読まれず、見出しも無名
✗ 安っぽい例:role/ラベルなし → 「ダイアログ」と認識されない
▸ <div role="dialog"
  aria-modal="true"
  aria-labelledby="t">
  <h2 id="t"> 削除しますか?
  <button> 削除
✓ 「ダイアログ・削除しますか?」
✓ プロの例:role + aria-modal + aria-labelledby で名前が読まれる

primaryDialog (Modal) Pattern | APG | W3C WAI-ARIA

primaryARIA: aria-modal attribute — MDN

03 — フォーカスを正しく管理する(開いたら内部、閉じたら起動元へ)

開いてもフォーカスがトリガーやページ先頭に残り、閉じたらページ冒頭へ飛ぶ実装は、キーボード操作の文脈を完全に破壊する。鉄則は「開いたら内部の最初のフォーカス可能要素へ移し、Tab/Shift+Tab はダイアログ内で循環させ、閉じたら起動元の要素へ .focus() で戻す」。手製なら開く直前にトリガー参照を保存しておく。ただしブラウザ chrome(アドレスバー等)への Tab 離脱は意図的に許容される——トラップするのはページ内だけだ。

トリガー
↓ 開く
モーダル(フォーカス来ず)
↓ 閉じる
↟ ページ冒頭へ飛ぶ
✗ 安っぽい例:フォーカスがトリガーに残り、閉じると先頭へ迷子
トリガー
↓ 開く
モーダル内へ移動 ●
↑ 閉じる
↩ 起動元トリガーへ復帰
✓ プロの例:開いたら内部へ、閉じたら起動元へ .focus() で戻す

primaryDialog (Modal) Pattern | APG | W3C WAI-ARIA

blogThere is No Need to Trap Focus on a Dialog Element | CSS-Tricks

04 — キーボードで閉じられる手段を必ず用意する

最大の落とし穴がこれだ。背景クリック(light dismiss)や × アイコンだけでは、キーボードやスクリーンリーダーの利用者が閉じられず操作不能になる。背景クリックは ARIA 要件ではない便利機能にすぎない。必須なのは「Esc で閉じる」と「Tab 順内にある可視の role="button" クローズ要素(×やキャンセル)」だ。<dialog> なら closedby="any" で背景クリック閉じを宣言的に足せるが、足しても Esc とクローズボタンは絶対に残す。

背景 背景 背景 背景 背景 背景 背景 背景 背景 背景 背景 背景
お知らせ

背景クリックだけで閉じる

Esc ✗ / ボタン ✗
✗ 安っぽい例:マウスのみ → キーボード利用者は閉じられない
背景 背景 背景 背景 背景 背景 背景 背景 背景 背景 背景 背景
×
お知らせ

Esc / × / キャンセル

Esc ✓ / ボタン ✓
✓ プロの例:Esc + 可視クローズボタン(背景クリックは任意で追加)

primaryDialog (Modal) Pattern | APG | W3C WAI-ARIA

primaryDialogs | University IT Accessibility — Stanford

05 — 背景を inert にし、scrim で暗転させる

scrim(暗転レイヤー)は「アプリの残りは操作不能」を視覚的に示す要素だ。暗転がないと階層が伝わらず安っぽい。逆に黒60%のようなベタ塗りは古い非統一値で、Material は scrim を 32% 不透明に統一した。コードでは背景を inert にして操作無効化と支援技術からの隠蔽を1宣言で行う。aria-modal="true" を名乗ってよいのは「コードで外部操作を無効化」かつ「視覚的に背景を覆う」両方が揃ったときだけだ。

背景 背景 背景 背景 背景 背景 背景
card
✗ 0%(暗転なし)
階層が伝わらず安っぽい
背景 背景 背景 背景 背景 背景 背景
card
✓ 32%(Material)
操作不能が伝わり読みやすい
背景 背景 背景 背景 背景 背景 背景
card
✗ 60%(旧・濃すぎ)
古い非統一値・重い印象

primaryDialogs — Material Design

primary[Docs] unify scrim opacity · Issue #4295

06 — モーダルは「割り込むコストに見合う」場面に限定する

モーダルは scrim で他の全作業をブロックする割り込みだ。NN/g は、警告・破壊的/不可逆操作の確認・フロー継続に必須な情報など「割り込むコストに見合う重要事項」に限れと明言している。ニュースレター登録のような些末な用途でモーダルを多用すると、鬱陶しく安っぽい印象を与える。それは非モーダルやインライン表示で十分だ。確認・警告系は role="alertdialog" を使う。

記事を読んでいる最中…… 記事 記事 記事 記事 記事 記事 記事
×
📧 登録して!

ニュースレターに登録

✗ 安っぽい例:些末な用件を割り込みで強制 → 鬱陶しい
アカウント設定 アカウント設定 アカウント設定 アカウント設定
アカウントを削除

この操作は取り消せません。

✓ プロの例:不可逆操作の確認 → role="alertdialog" で割り込む価値あり

secondaryModal & Nonmodal Dialogs: When (& When Not) to Use Them — NN/g

実装スニペット

/* ① ネイティブ <dialog>:中央寄せ + scrim(最小・推奨) */
dialog {
  margin: auto;            /* top layer 内で中央寄せ */
  position: fixed;
  inset: 0;
  border: none;
  border-radius: 12px;
  padding: 1.5rem;
  max-width: min(90vw, 480px);
}
/* showModal() のときだけ自動生成される暗転レイヤー */
dialog::backdrop {
  background-color: hsl(0 0% 0% / 0.32); /* Material 準拠の scrim 32% */
  backdrop-filter: blur(2px);
}
/* ② 開閉トランジション(display / overlay も含めてアニメ) */
dialog {
  opacity: 0;
  transition: opacity 0.2s ease, overlay 0.2s ease allow-discrete,
              display 0.2s ease allow-discrete;
}
dialog[open] { opacity: 1; }
@starting-style {
  dialog[open] { opacity: 0; }
}
dialog::backdrop {
  opacity: 0;
  transition: opacity 0.2s ease, overlay 0.2s ease allow-discrete,
              display 0.2s ease allow-discrete;
}
dialog[open]::backdrop { opacity: 1; }
@starting-style { dialog[open]::backdrop { opacity: 0; } }
<!-- ③ 宣言的な背景クリック閉じ(light dismiss) -->
<dialog id="d" closedby="any">
  <h2 id="title">確認</h2>
  <p>この操作は取り消せません。</p>
  <button autofocus>キャンセル</button>
  <button class="danger">削除する</button>
</dialog>
<!-- closedby="any":         背景クリック + Esc + ボタンで閉じる -->
<!-- closedby="closerequest": Esc + ボタンのみ(背景クリック不可) -->
<!-- <dialog> 自体に tabindex は付けない -->
/* ④ <dialog> が使えない場合のみ:手製 overlay + scrim */
.mod-overlay {
  position: fixed;
  inset: 0;                       /* top/right/bottom/left: 0 */
  background: rgba(0, 0, 0, 0.5); /* 黒50% scrim(旧来の定番値) */
  display: grid;
  place-items: center;
  z-index: 1000;
}
.mod-modal {
  background: #fff;
  border-radius: 12px;
  padding: 1.5rem;
  max-width: min(90vw, 480px);
}
/* JS: appRoot.inert = true; 閉じたら appRoot.inert = false; trigger.focus(); */
/* role="dialog" aria-modal="true" aria-labelledby を付与し、 */
/* フォーカストラップ・inert・trigger 復帰を JS で実装する */

チェックリスト

  • モーダル挙動が要るなら show()/open ではなく showModal() で開いている
  • role="dialog"(確認・警告は alertdialog)+ aria-modal="true" が付いている
  • 可視タイトルを aria-labelledby で参照、または aria-label がある(どちらか必須)
  • 開いたら内部の最初のフォーカス可能要素へフォーカスが移る(必要なら autofocus
  • Tab / Shift+Tab がダイアログ内で循環する(最後→Tab→最初)
  • 閉じたらフォーカスが起動元のトリガーへ .focus() で戻る
  • Esc で閉じられる、かつ Tab 順内に可視のクローズボタンがある(背景クリックだけにしない)
  • 背景を inert 化(手製時)し、scrim で暗転させている(黒32%目安/黒60%は避ける)
  • <dialog> 自体に tabindex を付けていない
  • そのモーダルは「割り込むコストに見合う」重要事項か(些末ならインライン表示にする)
  • closedby / @starting-style / allow-discrete はターゲットブラウザのサポートを確認した

限界 / 出典

scrim 値に唯一の正解はない:Material の統一値は 32%、12 Days of Web の実例は黒35%+blur 2px、wpdean は黒50%。いずれも credible だが文脈依存なので、背景の明度やブランドに応じて調整する。合意があるのは「旧来の黒60%は濃すぎとして非推奨」という点だけだ。
新しめの機能は本番前にサポート確認を:closedby 属性や @starting-style + transition-behavior: allow-discrete によるトランジションは比較的新しい CSS/HTML 機能。未対応環境ではアニメなしの即時表示や JS でのクリック外し判定にフォールバックを用意する。
「手動フォーカストラップ不要」は前提付き:これは <dialog> + showModal() / inert が使える前提の話。React のポータル実装や旧ブラウザ対応など <dialog> を使えない場合は、従来通りの手製トラップ・inert・trigger 復帰が依然必要だ。背景クリック閉じを足すのは自由だが、Esc と可視クローズボタンは省いてはいけない。

primaryDialog (Modal) Pattern | APG | W3C WAI-ARIA Authoring Practices

primary<dialog> HTML dialog element — MDN Web Docs

primaryARIA: aria-modal attribute — MDN Web Docs

primaryHTMLDialogElement: showModal() method — MDN

primaryDialogs — Material Design

primary[Docs] unify scrim opacity · Issue #4295 (material-components-android)

secondaryModal & Nonmodal Dialogs: When (& When Not) to Use Them — NN/g

blogHTML Dialog | 12 Days of Web

blogThere is No Need to Trap Focus on a Dialog Element | CSS-Tricks

blogHow to Build Accessible Modals with Focus Traps (2026 Guide) | UXPin

secondaryDialogs | University IT Accessibility — Stanford

blogPractical CSS Modals Examples You Can Use