結論
プロは「雑なモーダル」を 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 無し確認
showModal() で開く
inert / 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() なら暗黙付与されるが、手製なら明示が必須だ。
primaryDialog (Modal) Pattern | APG | W3C WAI-ARIA
primaryARIA: aria-modal attribute — MDN
03 — フォーカスを正しく管理する(開いたら内部、閉じたら起動元へ)
開いてもフォーカスがトリガーやページ先頭に残り、閉じたらページ冒頭へ飛ぶ実装は、キーボード操作の文脈を完全に破壊する。鉄則は「開いたら内部の最初のフォーカス可能要素へ移し、Tab/Shift+Tab はダイアログ内で循環させ、閉じたら起動元の要素へ .focus() で戻す」。手製なら開く直前にトリガー参照を保存しておく。ただしブラウザ chrome(アドレスバー等)への Tab 離脱は意図的に許容される——トラップするのはページ内だけだ。
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 ✓ / ボタン ✓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" を名乗ってよいのは「コードで外部操作を無効化」かつ「視覚的に背景を覆う」両方が揃ったときだけだ。
primaryDialogs — Material Design
primary[Docs] unify scrim opacity · Issue #4295
06 — モーダルは「割り込むコストに見合う」場面に限定する
モーダルは scrim で他の全作業をブロックする割り込みだ。NN/g は、警告・破壊的/不可逆操作の確認・フロー継続に必須な情報など「割り込むコストに見合う重要事項」に限れと明言している。ニュースレター登録のような些末な用途でモーダルを多用すると、鬱陶しく安っぽい印象を与える。それは非モーダルやインライン表示で十分だ。確認・警告系は 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はターゲットブラウザのサポートを確認した
限界 / 出典
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