TECH · dialog要素

モーダルを自作しない

サンプルとコード

01

showModal() のモーダル

単独で開く ↗

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 にする。
02

method=dialog で戻り値を受け取る

単独で開く ↗

フォームに 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 を指定する。
03

popover 属性で軽い重ね表示

単独で開く ↗

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。
04

横から出るドロワー

単独で開く ↗

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キーでフォーカスが背後の要素へ抜ける。閉じたあとフォーカスがどこへ行ったか分からない。開いている間だけ bodyoverflow: hidden を当てる小細工、フォーカスを閉じ込めるための長いイベント処理。そのすべてを dialog 要素が最初から持っています。

showModal() を呼ぶだけで、フォーカスは閉じ込められ、Escは効き、背面は不活性になり、::backdrop で背景も装飾できる。いま自作する理由は、ほとんど残っていません。

show() では駄目な理由

最初に間違えやすいのが show()showModal() の取り違えです。

show() は単に表示するだけです。背面は操作でき、Escも効かず、::backdrop も出ません。確認ダイアログをこれで開くと、背後のボタンが押せてしまいます。モーダルにしたいなら必ず showModal() です。

::backdrop にも一つ癖があります。これは dialog の子要素ではないため、CSS変数も色も継承されません。背景色は必ず明示してください。

show() は単に表示するだけで背面が操作できる。showModal() だけがモーダルになる show() dialog 背面のボタンが押せる/Esc が効かない ::backdrop も出ない showModal() dialog 背面が不活性/Esc で閉じる フォーカスが中に閉じ込められる
名前が似ているので取り違えが起きます。確認ダイアログを show() で開くと、背後の「削除」ボタンがそのまま押せます。モーダルにしたいなら必ず showModal() です。

閉じる処理を1か所に集める

dialog の設計で気持ちいいのは、閉じ方が何通りあっても close イベントに集まることです。ボタンで閉じても、Escで閉じても、背景をクリックして閉じても、後始末は1か所に書けます。

そして close() に渡した引数は returnValue に入ります。フォームなら method="dialog" を付けるだけで、送信でダイアログが閉じ、押したボタンの valuereturnValue になる。JavaScriptで閉じる処理を書く必要がありません。Escで閉じたときの returnValue は空文字なので、キャンセルと同じ扱いに自然と収まります。

閉じ方が何通りあっても close イベントに集まるので、後始末は1か所に書ける ボタンで閉じる Esc で閉じる 背景クリック close イベント 後始末 returnValue 閉じ方が何通りあっても集約先は1つ。 form に method="dialog" を付ければ、押したボタンの value が returnValue に入る
分岐が増えるほど、どこかの経路で後始末を書き忘れます。集約先が1つに決まっていれば、その事故が起きる場所自体がありません。

アニメーションだけは3点セットが要る

唯一やや面倒なのが開閉のアニメーションです。dialogdisplaynoneblock を行き来するため、普通の transition では補間されません。

必要なのは3つ。transition のプロパティに displayoverlay を含めること、transition-behavior: allow-discrete を指定すること、開始状態を @starting-style で書くこと。どれか1つ欠けると動きません。逆に揃えば、フェードもスライドも自然に効きます。

dialog の開閉アニメーションは3つの指定が揃って初めて動く 3つ揃って初めて動く transition に display と overlay を含める transition-behavior: allow-discrete @starting-style 開始状態を書く どれか1つ欠けると動かない dialog は display が none と block を行き来するので、通常の transition では補間されない
「アニメーションが効かない」と感じたら、3つのうちどれが抜けているかを順に確かめるのが早道です。1つでも欠けると、部分的に動くのではなく完全に無反応になります。

余白を変えるだけでドロワーになる

dialog は既定で画面中央に出ますが、位置は margin で決まっています。margin: 0 0 0 auto にして height: 100dvh を与えれば、それだけで右端から出るドロワーです。

自作のドロワーで面倒なのは、開いている間の背面スクロールの停止と、フォーカスの閉じ込めでした。showModal() を使えば両方とも標準動作のまま手に入ります。中央のモーダルとドロワーが、同じ仕組みの見た目違いになるわけです。

dialog の位置は margin で決まる。余白を変えるだけでドロワーになる 既定(中央) margin: auto ドロワー margin: 0 0 0 auto height: 100dvh 位置は margin で決まっている。 背面スクロールの停止もフォーカスの閉じ込めも showModal のまま使える
自作のドロワーで面倒なのは、見た目ではなく背面の制御のほうでした。それを標準動作のまま受け取れるので、書き換えるのは余白の指定だけです。

popover との住み分け

popover 属性は、もっと軽い重ね表示のための仕組みです。JavaScriptを1行も書かずに開閉でき、popover="auto" なら外側クリックとEscでの解除まで面倒を見てくれます。どちらも最前面に出るので、z-index の調整も不要です。

判断の基準は一つだけ。閉じずに他を触れてよいか。触れさせたくない、決めるまで先へ進ませたくないなら dialogshowModal()。見ながら他の操作を続けてよいなら popover。削除の確認は前者、補足説明やメニューは後者です。

閉じずに他を触れてよいかどうかで dialog と popover を選び分ける dialog(showModal) 閉じるまで他を触らせない 削除の確認・入力の完了 popover 見ながら他の操作を続けられる 補足説明・メニュー・通知 判断の基準はひとつ ── 閉じずに他を触れてよいか どちらも最前面に出るので z-index の調整は要らない。 popover は JavaScript を書かずに開閉できる
迷ったときに立ち戻る問いはこれだけです。決めるまで進ませたくないなら dialog、見ながら続けてよいなら 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 が向きます。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術