TECH · dialog要素
モーダルを自作しない
dialog要素とpopover属性のサンプル集。showModalによるモーダル、method=dialogでの戻り値、popoverによる軽い重ね表示、横から出るドロワーを、フォーカス管理とアニメーションまで含めて収録します。
サンプルとコード
Escで閉じる、Tabが中を回る、背面がスクロールしない、閉じたらフォーカスが戻る。これらは全部ブラウザの標準動作で、自分で書く必要がありません。
HTML
<button class="btn danger" type="button" id="open">アカウントを削除する</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">本当に削除しますか</h2>
<p>保存済みのプロジェクトと共有リンクがすべて失われます。この操作は取り消せません。</p>
<div class="dlg-actions">
<button class="btn" type="button" data-close>やめる</button>
<button class="btn danger" type="button" id="confirm">削除する</button>
</div>
</dialog> CSS
/* dialog は既定で display:none。開いたときだけ表示される。
位置決めもブラウザ任せで、中央寄せの記述は要らない。 */
dialog {
border: 0;
border-radius: 16px;
padding: 1.5rem 1.4rem 1.3rem;
width: min(420px, calc(100% - 2rem));
color: var(--ink);
box-shadow: 0 30px 60px -25px rgba(20, 24, 36, .5);
}
/* ::backdrop は dialog の子ではないので、変数も色も継承されない。
ここで明示的に指定する。 */
dialog::backdrop {
background: rgba(20, 24, 36, .55);
backdrop-filter: blur(2px);
}
/* 開閉のアニメーション。display が none と block を行き来するため、
transition-behavior: allow-discrete と @starting-style が要る。 */
dialog, dialog::backdrop {
transition: opacity .22s ease, display .22s allow-discrete,
overlay .22s allow-discrete;
opacity: 0;
}
dialog[open], dialog[open]::backdrop { opacity: 1; }
@starting-style {
dialog[open], dialog[open]::backdrop { opacity: 0; }
}
dialog[open] { transform: none; }
@starting-style { dialog[open] { transform: translateY(8px); } }
dialog { transform: translateY(8px); transition-property: opacity, transform, display, overlay; }
@media (prefers-reduced-motion: reduce) {
dialog, dialog::backdrop { transition: none; }
dialog, dialog[open] { transform: none; }
}
dialog h2 { margin: 0 0 .6rem; font-size: 1.15rem; line-height: 1.5; }
dialog p { margin: 0 0 1.3rem; color: var(--muted); line-height: 1.85; }
.dlg-actions { display: flex; gap: .6rem; justify-content: flex-end; }
.btn {
font: inherit; line-height: 1.7; cursor: pointer; font-weight: 600;
padding: .55rem 1.1rem; border-radius: 9px;
border: 1px solid var(--line); background: #fff; color: var(--muted);
}
.btn.primary { border-color: transparent; background: var(--accent); color: #fff; font-weight: 700; }
.btn.danger { border-color: transparent; background: var(--danger); color: #fff; font-weight: 700; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } JavaScript
const dlg = document.getElementById("dlg");
const opener = document.getElementById("open");
const result = document.getElementById("result");
// show() ではなく showModal()。前者は背面が操作できてしまい、
// Esc も効かず ::backdrop も出ない。モーダルなら必ず showModal()。
opener.addEventListener("click", () => dlg.showModal());
// 閉じる系は close() に集約する。引数を渡すと returnValue に入る。
dlg.querySelector("[data-close]").addEventListener("click", () => dlg.close("cancel"));
document.getElementById("confirm").addEventListener("click", () => dlg.close("delete"));
// 背景クリックで閉じる。dialog 自身が背景の当たり判定を兼ねるので、
// クリック位置が中身の外かどうかを矩形で判定する。
dlg.addEventListener("click", (e) => {
const r = dlg.getBoundingClientRect();
const inside =
e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) dlg.close("cancel");
});
// close は Esc でも発火する。後始末は1か所にまとめられる。
dlg.addEventListener("close", () => {
result.textContent =
dlg.returnValue === "delete" ? "削除を実行しました(サンプルです)" : "取り消しました。";
// フォーカスは自動で元へ戻るが、開いた要素が消える画面では明示的に移す
opener.focus();
}); AIへの指示文
dialog 要素で確認モーダルを作ってください。
要件:
- 開くのは showModal()。show() では背面が操作でき Esc も効かず
::backdrop も出ない、という違いをコメントで書く。
- ::backdrop は dialog の子ではないので変数も色も継承されない。
背景色は明示的に指定する。
- 閉じる操作は close() に集約し、引数で returnValue を渡す。
Esc でも close イベントが発火するので、後始末は1か所にまとめられる。
- 背景クリックで閉じる。dialog 自身が背景の当たり判定を兼ねるので、
getBoundingClientRect でクリック位置が中身の外かを判定する。
- 開閉のアニメーションには transition-behavior: allow-discrete と
@starting-style を使う。display が none と block を行き来するため。
- prefers-reduced-motion では transition を切る。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
フォームに method="dialog" を付けると、送信でダイアログが閉じ、押したボタンの value が returnValue に入ります。閉じる処理を自分で書く必要がありません。
HTML
<button class="btn primary" type="button" id="open">配送先を編集する</button>
<dialog id="dlg" aria-labelledby="t">
<h2 id="t">配送先の編集</h2>
<!-- method="dialog" が要点。送信でダイアログが閉じ、
submitter の value が returnValue に渡る。 -->
<form method="dialog" id="form">
<div class="field">
<label for="zip">郵便番号</label>
<input id="zip" name="zip" type="text" inputmode="numeric"
value="150-0001" pattern="[0-9]{3}-?[0-9]{4}" required />
</div>
<div class="field">
<label for="addr">住所</label>
<input id="addr" name="addr" type="text" value="東京都渋谷区神宮前1-2-3" required />
</div>
<div class="field">
<label for="time">希望時間帯</label>
<select id="time" name="time">
<option value="none">指定なし</option>
<option value="am" selected>午前中</option>
<option value="pm">14時〜18時</option>
</select>
</div>
<div class="dlg-actions">
<!-- value 無しの cancel なら returnValue は空文字になる -->
<button class="btn" type="submit" value="cancel">やめる</button>
<button class="btn primary" type="submit" value="save">保存する</button>
</div>
</form>
</dialog> CSS
dialog {
border: 0; border-radius: 16px; padding: 1.4rem;
width: min(400px, calc(100% - 2rem)); color: var(--ink);
box-shadow: 0 30px 60px -25px rgba(20, 24, 36, .5);
}
dialog::backdrop { background: rgba(20, 24, 36, .5); }
dialog h2 { margin: 0 0 1rem; font-size: 1.1rem; line-height: 1.5; }
.field { display: grid; gap: .3rem; margin-bottom: 1rem; }
.field label { font-size: .86rem; font-weight: 600; line-height: 1.7; }
.field input, .field select {
font: inherit; line-height: 1.7; color: inherit;
padding: .55rem .75rem; border: 1px solid var(--line); border-radius: 9px;
}
.field input:focus, .field select:focus {
outline: 0; border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(37, 99, 235, .15);
}
.dlg-actions { display: flex; gap: .6rem; justify-content: flex-end; }
.btn {
font: inherit; line-height: 1.7; cursor: pointer; font-weight: 600;
padding: .55rem 1.1rem; border-radius: 9px;
border: 1px solid var(--line); background: #fff; color: var(--muted);
}
.btn.primary { border-color: transparent; background: var(--accent); color: #fff; font-weight: 700; } JavaScript
const dlg = document.getElementById("dlg");
const form = document.getElementById("form");
const out = document.getElementById("out");
document.getElementById("open").addEventListener("click", () => dlg.showModal());
// 閉じたあとに1か所で処理する。押されたボタンの value は returnValue に入る。
// Esc で閉じた場合の returnValue は空文字なので、キャンセルと同じ扱いにできる。
dlg.addEventListener("close", () => {
if (dlg.returnValue !== "save") {
out.textContent = "returnValue: " + JSON.stringify(dlg.returnValue) + "\n保存せずに閉じました。";
return;
}
const data = Object.fromEntries(new FormData(form));
out.textContent =
"returnValue: " + JSON.stringify(dlg.returnValue) + "\n" +
JSON.stringify(data, null, 2);
}); AIへの指示文
dialog の中にフォームを置き、押したボタンによって結果を出し分けてください。
要件:
- form に method="dialog" を付ける。送信でダイアログが閉じ、
submitter の value が returnValue に渡る仕組みをコメントで書く。
- ボタンは type="submit" にして value="cancel" と value="save" を持たせる。
- close イベントで returnValue を見て処理を分ける。
Esc で閉じた場合の returnValue は空文字なので、キャンセルと同じ扱いにできる。
- 保存時は FormData から値を取り出して表示する。
- すべての入力に label を for と id で結び、autocomplete を指定する。
JavaScriptを1行も書かずに開閉できます。popovertarget が要素を結び付け、外側クリックとEscでの解除まで面倒を見ます。dialog との使い分けも並べました。
HTML
<div class="row">
<!-- popovertarget が id を指すだけで開閉が成立する -->
<button class="btn" type="button" popovertarget="tip-fee">手数料について</button>
<button class="btn" type="button" popovertarget="tip-ship">配送について</button>
<button class="btn" type="button" popovertarget="panel-manual">手動で閉じる例</button>
</div>
<!-- 既定は popover="auto"。外側クリックと Esc で閉じ、
同時に開けるのは1つだけ(別の auto を開くと前のが閉じる) -->
<div id="tip-fee" popover>
<h3>手数料</h3>
<p>お支払い時の手数料は当社が負担します。分割払いをお選びの場合のみ、カード会社所定の分割手数料が発生します。</p>
</div>
<div id="tip-ship" popover>
<h3>配送</h3>
<p>全国一律 500 円、5,000 円以上のご注文で無料です。離島の一部地域のみ中継料をいただく場合があります。</p>
</div>
<!-- manual は外側クリックでは閉じない。閉じるボタンを自分で置く -->
<div id="panel-manual" popover="manual">
<h3>手動で閉じる</h3>
<p>popover="manual" は外側をクリックしても Esc でも閉じません。閉じる手段を必ず用意してください。</p>
<p style="margin-top:.8rem">
<button class="btn" type="button" popovertarget="panel-manual" popovertargetaction="hide">閉じる</button>
</p>
</div> CSS
/* popover 属性を付けた要素は、既定で最前面(top layer)に出る。
z-index の管理から解放されるのが最大の利点。 */
[popover] {
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem 1.1rem;
width: min(320px, calc(100% - 2rem));
box-shadow: 0 24px 44px -22px rgba(20, 24, 36, .5);
/* 既定は画面中央。位置を寄せたいときは margin と inset で調整する */
margin: auto;
}
[popover] h3 { margin: 0 0 .4rem; font-size: 1rem; line-height: 1.6; }
[popover] p { margin: 0; color: var(--muted); font-size: .9rem; line-height: 1.85; }
/* popover="auto" の背景。manual では出ない */
[popover]::backdrop { background: rgba(20, 24, 36, .25); }
.btn {
font: inherit; line-height: 1.7; cursor: pointer; font-weight: 600;
padding: .55rem 1.1rem; border-radius: 9px;
border: 1px solid var(--line); background: #fff; color: var(--ink);
}
.btn:hover { background: #f2f4f9; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } AIへの指示文
popover 属性で補足情報の重ね表示を作ってください。JavaScript は使いません。
要件:
- ボタンに popovertarget、中身に popover 属性を付けるだけで開閉させる。
- 既定の popover="auto" は外側クリックと Esc で閉じ、
同時に開けるのは1つだけ、という挙動をコメントで書く。
- popover="manual" の例も1つ置き、閉じるボタンを
popovertargetaction="hide" で用意する。
- popover は top layer に出るので z-index の管理が不要、という利点を書く。
- dialog(showModal)との使い分けを本文で説明する。
決めるまで進ませないなら dialog、見ながら続けられるなら popover。
dialog の余白指定を変えるだけで、中央のモーダルが横からのドロワーになります。背面スクロールの停止とフォーカスの閉じ込めは標準動作のまま使えます。
HTML
<button class="btn" type="button" id="open">メニューを開く</button>
<dialog class="drawer" id="drawer" aria-labelledby="drawer-title">
<div class="drawer-head">
<h2 id="drawer-title">メニュー</h2>
<button class="drawer-close" type="button" id="close" aria-label="メニューを閉じる">×</button>
</div>
<nav>
<ul>
<li><a href="#" aria-current="page">ダッシュボード</a></li>
<li><a href="#">注文</a></li>
<li><a href="#">商品</a></li>
<li><a href="#">お客様</a></li>
<li><a href="#">設定</a></li>
</ul>
</nav>
</dialog> CSS
/* dialog の既定は中央寄せ。ドロワーにするには余白の指定を上書きする。
右端に寄せ、高さを画面いっぱいにするだけでよい。 */
dialog.drawer {
border: 0;
margin: 0 0 0 auto; /* 右端へ寄せる */
height: 100dvh;
max-height: 100dvh;
width: min(320px, 86vw);
border-radius: 16px 0 0 16px;
padding: 1.3rem 1.2rem;
color: var(--ink);
box-shadow: -24px 0 50px -30px rgba(20, 24, 36, .6);
}
dialog.drawer::backdrop { background: rgba(20, 24, 36, .45); }
/* スライドインは transform で。display の切替をまたぐので
allow-discrete と @starting-style を併用する。 */
dialog.drawer {
translate: 100% 0;
transition: translate .26s cubic-bezier(.2,.8,.2,1),
display .26s allow-discrete,
overlay .26s allow-discrete;
}
dialog.drawer[open] { translate: 0 0; }
@starting-style { dialog.drawer[open] { translate: 100% 0; } }
dialog.drawer::backdrop { opacity: 0; transition: opacity .26s ease, display .26s allow-discrete, overlay .26s allow-discrete; }
dialog.drawer[open]::backdrop { opacity: 1; }
@starting-style { dialog.drawer[open]::backdrop { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
dialog.drawer, dialog.drawer::backdrop { transition: none; }
dialog.drawer { translate: 0 0; }
}
.drawer-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
.drawer-head h2 { margin: 0; font-size: 1.05rem; line-height: 1.5; }
.drawer-close {
margin-left: auto; border: 0; background: none; cursor: pointer;
font: inherit; font-size: 1.2rem; line-height: 1; color: var(--muted);
padding: .3rem .5rem; border-radius: 8px;
}
.drawer-close:hover { background: #f2f4f9; color: var(--ink); }
.drawer nav ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .2rem; }
.drawer nav a {
display: block; padding: .55rem .7rem; border-radius: 9px;
color: var(--muted); text-decoration: none; line-height: 1.7;
}
.drawer nav a:hover { background: #f2f4f9; color: var(--ink); }
.drawer nav a[aria-current] { background: #eaf1fe; color: var(--accent); font-weight: 600; }
.btn {
font: inherit; line-height: 1.7; cursor: pointer; font-weight: 700;
padding: .6rem 1.2rem; border-radius: 9px; border: 0;
background: var(--accent); color: #fff;
}
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } JavaScript
const drawer = document.getElementById("drawer");
const opener = document.getElementById("open");
opener.addEventListener("click", () => drawer.showModal());
document.getElementById("close").addEventListener("click", () => drawer.close());
// 背景クリックで閉じる。ドロワーは画面端に寄っているので、
// 矩形の外側=背景と判定できる。
drawer.addEventListener("click", (e) => {
const r = drawer.getBoundingClientRect();
const inside =
e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) drawer.close();
});
// メニュー内のリンクを押したら閉じる。単一ページの画面では必須。
drawer.querySelectorAll("nav a").forEach((a) =>
a.addEventListener("click", () => drawer.close()),
); AIへの指示文
dialog を使って横から出るドロワーメニューを作ってください。
要件:
- dialog の margin を 0 0 0 auto にして右端へ寄せ、
height を 100dvh にする。中央寄せの既定を上書きするだけでよい。
- スライドインは translate で行い、transition に display と overlay の
allow-discrete を含める。@starting-style で開始位置を指定する。
- 背景クリックで閉じる。メニュー内のリンクを押したときも閉じる。
- 閉じるボタンには aria-label を付ける。
- prefers-reduced-motion では transition を切り、位置も動かさない。
この技術について
モーダルを自作すると、必ず同じところでつまずきます。背面のスクロールが動いてしまう。Escで閉じない。Tabキーでフォーカスが背後の要素へ抜ける。閉じたあとフォーカスがどこへ行ったか分からない。開いている間だけ body に overflow: hidden を当てる小細工、フォーカスを閉じ込めるための長いイベント処理。そのすべてを dialog 要素が最初から持っています。
showModal() を呼ぶだけで、フォーカスは閉じ込められ、Escは効き、背面は不活性になり、::backdrop で背景も装飾できる。いま自作する理由は、ほとんど残っていません。
show() では駄目な理由
最初に間違えやすいのが show() と showModal() の取り違えです。
show() は単に表示するだけです。背面は操作でき、Escも効かず、::backdrop も出ません。確認ダイアログをこれで開くと、背後のボタンが押せてしまいます。モーダルにしたいなら必ず showModal() です。
::backdrop にも一つ癖があります。これは dialog の子要素ではないため、CSS変数も色も継承されません。背景色は必ず明示してください。
閉じる処理を1か所に集める
dialog の設計で気持ちいいのは、閉じ方が何通りあっても close イベントに集まることです。ボタンで閉じても、Escで閉じても、背景をクリックして閉じても、後始末は1か所に書けます。
そして close() に渡した引数は returnValue に入ります。フォームなら method="dialog" を付けるだけで、送信でダイアログが閉じ、押したボタンの value が returnValue になる。JavaScriptで閉じる処理を書く必要がありません。Escで閉じたときの returnValue は空文字なので、キャンセルと同じ扱いに自然と収まります。
アニメーションだけは3点セットが要る
唯一やや面倒なのが開閉のアニメーションです。dialog は display が none と block を行き来するため、普通の transition では補間されません。
必要なのは3つ。transition のプロパティに display と overlay を含めること、transition-behavior: allow-discrete を指定すること、開始状態を @starting-style で書くこと。どれか1つ欠けると動きません。逆に揃えば、フェードもスライドも自然に効きます。
余白を変えるだけでドロワーになる
dialog は既定で画面中央に出ますが、位置は margin で決まっています。margin: 0 0 0 auto にして height: 100dvh を与えれば、それだけで右端から出るドロワーです。
自作のドロワーで面倒なのは、開いている間の背面スクロールの停止と、フォーカスの閉じ込めでした。showModal() を使えば両方とも標準動作のまま手に入ります。中央のモーダルとドロワーが、同じ仕組みの見た目違いになるわけです。
popover との住み分け
popover 属性は、もっと軽い重ね表示のための仕組みです。JavaScriptを1行も書かずに開閉でき、popover="auto" なら外側クリックとEscでの解除まで面倒を見てくれます。どちらも最前面に出るので、z-index の調整も不要です。
判断の基準は一つだけ。閉じずに他を触れてよいか。触れさせたくない、決めるまで先へ進ませたくないなら dialog の showModal()。見ながら他の操作を続けてよいなら popover。削除の確認は前者、補足説明やメニューは後者です。
使いどころとつまずきどころ
向いている場面
- 確認・警告のモーダルダイアログ
- 入力フォームを重ねて出す画面
- スマートフォンで横から出すメニュー
- 小さな補足情報を出すポップオーバー
つまずきやすい点
- show() と showModal() は挙動が違う。モーダルは後者
- 閉じたあとに開いた要素へフォーカスを戻す配慮が要る
- ::backdrop は dialog の子ではないので継承されない
- アニメーションには @starting-style か display の遷移指定が要る
よくある質問
show() と showModal() はどう違いますか。
showModal() だけがモーダルです。背面が不活性になり、Escで閉じ、フォーカスがダイアログ内に閉じ込められ、::backdrop が出ます。show() は単に表示するだけで、背面も操作でき、Escも効きません。確認ダイアログを show() で開くと、背後のボタンが押せてしまいます。
閉じたあとのフォーカスは自分で戻す必要がありますか。
基本は不要です。ブラウザが開いた要素へ戻します。ただし、開いたボタン自体が処理の結果として消える画面(一覧から行を削除した場合など)では、戻り先が無くなるので明示的に移してください。何もしないとフォーカスが body に落ち、キーボード操作の位置が失われます。
開閉のアニメーションが効きません。
dialog は display が none と block を行き来するため、通常の transition では補間されません。transition に display と overlay を含め、transition-behavior: allow-discrete を指定し、開始状態を @starting-style で書く必要があります。3点セットで初めて動きます。
dialog と popover はどちらを使うべきですか。
「閉じずに他を触れてよいか」で決めてください。触れさせたくない、決めるまで進ませたくないなら dialog の showModal()。見ながら他の操作を続けてよいなら popover です。補足説明・メニュー・通知は popover、削除の確認や入力の完了は dialog が向きます。