サイトの検収で、最後まで残る指摘がモーダルまわりだ、という経験はないでしょうか。デザインどおりに見えているのに、確認項目を潰していくと必ず何かが引っかかります。Escキーで閉じない。開いている間に背面がスクロールしてしまう。Tabキーを押し続けると、フォーカスがモーダルの外のリンクに抜けていく。
同じ箇所が毎回落ちるのは、実装者の注意力の問題ではありません。そこは本来ブラウザが持っている機能で、自前のdiv要素で組み立てた瞬間に全部を手で書き直す必要が生まれる領域だからです。そして手で書いたものは、案件ごとに少しずつ違う抜け方をします。
なお「そもそもモーダルで出すべきか、別ページにすべきか」という手前の判断はモーダルか別ページか、で迷わないで扱っています。この記事は、モーダルで出すと決めたあとの実装の話です。
div で組むと必ず自分で書くことになるもの
自前実装のモーダルで書き漏らしが起きるのは、だいたい次の4か所です。
- フォーカスの閉じ込め — Tab・Shift+Tabがモーダル内で循環すること。最初と最後の要素を検出して手で戻す実装になりがちで、内容が動的に変わると壊れます
- 背面の不活性化 — 背後のリンクやボタンがキーボードとスクリーンリーダーから見えなくなること。
aria-hiddenの付け外しを自分で管理することになります - Escキー — キーイベントを拾って閉じる処理。ネストしたモーダルや、入力中のIME確定との衝突で事故が起きます
- 重なり順 —
z-indexの数値をどこまで上げるか。ヘッダーの追従バーやサードパーティのチャットウィジェットと競り合いになり、案件が進むほど数字が大きくなっていきます
このうち最後の z-index 争いは、実装の巧拙ではなく構造の問題です。同じ積み重ね文脈の中で数字を比べている限り、勝ったり負けたりが続きます。
showModal() が肩代わりする範囲
<dialog> 要素を showModal() で開くと、上の4つがまとめてブラウザ側の処理になります。
<dialog id="confirm-dialog">
<form method="dialog">
<h2>送信内容の確認</h2>
<p>この内容で送信します。よろしいですか。</p>
<button value="cancel">キャンセル</button>
<button value="submit">送信する</button>
</form>
</dialog>
<button id="open">確認する</button>
const dialog = document.getElementById('confirm-dialog');
document.getElementById('open').addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('close', () => {
// dialog.returnValue に押されたボタンの value が入る
console.log(dialog.returnValue);
});
showModal() で開いた場合、フォーカスはダイアログ内に閉じ込められ、背面の要素は不活性になり、Escキーで閉じます。表示はトップレイヤーという通常の積み重ね文脈の外側に置かれるため、z-index を書く必要がありません。ヘッダーやチャットウィジェットとの競合は、この時点で消えます。
method="dialog" のフォームを使うと、送信時にダイアログが閉じ、押されたボタンの value が returnValue に入ります。「キャンセルされたのか、確定されたのか」を判別するための状態変数を自分で持たなくて済みます。
背景の暗幕は ::backdrop 疑似要素として存在するので、CSSから直接触れます。
#confirm-dialog::backdrop {
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(2px);
}
なお、同じ <dialog> でも show()(非モーダル)で開いた場合は、フォーカスの閉じ込めも背面の不活性化も暗幕も付きません。モーダルとして使うなら showModal() を使う、というのがここでの分岐点です。

背景クリックで閉じる、を素直に書く
ここが <dialog> でいちばん躓く箇所です。既定では、暗幕をクリックしてもダイアログは閉じません。モーダルは「答えるべき問い」として設計されているため、既定は粘る側に倒れています。
長いあいだ使われてきた回避策は、ダイアログ自身のクリックイベントを見て、イベントの発生元がダイアログ要素そのものだったら閉じる、というものでした。暗幕はダイアログの子要素ではないので、暗幕へのクリックはダイアログ要素に届きます。
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
ただしこの書き方には既知の穴があります。ダイアログ自身に padding を付けていると、その余白部分のクリックもダイアログ要素そのものへのクリックになるため、中身の縁を押しただけで閉じてしまいます。内側にラッパー要素を置いてそちらに余白を持たせる、という追加の対処が要ります。
これを属性ひとつで置き換えるのが closedby です。
| 値 | 閉じる操作 |
|---|---|
any | 外側クリックとEscの両方で閉じる(ポップオーバーと同じ挙動) |
closerequest | Escや端末の戻る操作では閉じるが、外側クリックでは閉じない |
none | コードから閉じたときだけ閉じる |
<dialog id="confirm-dialog" closedby="any">
<!-- ... -->
</dialog>
closerequest はモーダルの既定の挙動と同じなので、実務で書く価値があるのは any と none です。none は「保存されていない入力がある間は誤操作で閉じさせたくない」ケースで効きます。
対応状況と、フォールバックの書き方
closedby は Chrome と Edge が 134、Firefox が 137 で対応し、Safari も Interop 2026 の対象として追随が進んでいます。ただし執筆時点でまだ Baseline には到達していません。受託の納品物では、Safari の古いバージョンを含む動作保証を求められることが多いはずなので、属性だけに頼る書き方は避けます。
現実的なのは、属性を書いたうえで、対応していないブラウザ向けに従来のクリック判定を残す形です。属性が効いている環境では二重に閉じる処理が走らないよう、対応の有無で分岐します。
const supportsClosedBy = 'closedBy' in HTMLDialogElement.prototype;
if (!supportsClosedBy) {
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
}
閉じるときのアニメーションを付ける場合は、close() ではなく requestClose() を使います。requestClose() は cancel イベントを発火させたうえで閉じるため、「保存していない変更がありますが閉じますか」の確認や、トランジションの完了を待ってから実際に閉じる処理を挟めます。
トップレイヤーの要素は表示と非表示の切り替えが display の変化を伴うため、素直に transition を書いても効きません。@starting-style と transition-behavior: allow-discrete を組み合わせる必要があり、ここは対応状況を確認したうえで、アニメーションが効かなくても機能上は問題ない書き方(progressive enhancement)にしておくのが安全です。
同じく「ブラウザ標準の部品をどこまで自前で置き換えるか」という判断は、フォーム部品でも繰り返し出てきます。そちらの現在地はCSSでフォーム部品をどこまで作り込めるかにまとめています。
次にやること
いま抱えている案件のモーダルが div で組まれているなら、まずTabキーを押し続けてフォーカスが外に抜けるかどうかだけ確認してください。抜けるなら、そのモーダルはスクリーンリーダー利用者にとって背後のページと地続きの状態です。
抜けることが分かったら、<dialog> + showModal() への置き換えを検討する価値があります。見た目のCSSはほぼそのまま流用でき、消えるのはフォーカス管理と z-index の調整コードのほうです。
既存サイトのアクセシビリティ改修や、コンポーネントの標準化を含めて相談したい場合は、グリームハブの開発・AI・自動化のご相談で承っています。既存実装の構成によって置き換えの範囲は変わるため、個別にご相談ください。お問い合わせからどうぞ。
Sources
- Using and Styling the Dialog Element — CSS-Tricks
- <dialog>: The Dialog element — MDN Web Docs
- HTMLDialogElement: requestClose() method — MDN Web Docs
- <dialog closedby> — Web platform features explorer
- Revisiting how to dismiss native HTML dialog elements when the backdrop is clicked — Go Make Things
- Enhance Dialogs with the closedby Attribute — Oida, is des org!