HTML <dialog> 要素 実践ガイド ― JSライブラリなしでアクセシブルなモーダルを作る

モーダルを自前で実装するために大量のJSを書いていたあの頃にさよなら。<dialog> 要素なら、フォーカス管理もEscキー対応もブラウザがやってくれます。

モーダル実装、自前で書くと意外としんどい

「モーダル作って」と言われると、軽いタスクに見えて実はけっこう奥が深い、というのは現場で何度も体験することです。背景に半透明レイヤーを重ねて、中央に箱を置いて——ここまではすぐ書ける。問題はそこから先です。Escキーで閉じる、背景クリックで閉じる、開いたときに中の入力欄にフォーカスを当てる、開いている間は背景のリンクにTabキーで飛んでいかないようにする(フォーカストラップ)、閉じた後は元のボタンにフォーカスを戻す。気付くとjQueryプラグインかReactのモーダルライブラリを探している、という流れ、よくありますよね。

実は、これらすべてを <dialog> 要素がブラウザネイティブで面倒見てくれます。showModal() を呼ぶだけで、フォーカストラップもEscキーも動く。スクリーンリーダーにも「ダイアログが開いたよ」と正しく伝わる。HTMLの仕様としてアクセシビリティが組み込まれているのが、この要素の最大の魅力です。

📌 ポイント
モーダルUIに必要な「フォーカス管理」「Escキー対応」「他の要素を不活性化(inert)」「最前面に表示」といった機能は、<dialog> がデフォルトで提供してくれます。自前のCSSとJSで再現するより、はるかに堅牢でアクセシブルです。

最小構成:showModal() と close() の2つだけ

使い方は驚くほどシンプルです。<dialog> をHTMLに置いて、JSで showModal() を呼ぶ。閉じるときは close()。これだけで、モーダルとして必要な振る舞いが一通り手に入ります。

最小限のモーダル実装
<button id="openBtn">開く</button>

<dialog id="myDialog">
  <h4>お知らせ</h4>
  <p>これは <dialog> 要素のモーダルです。</p>
  <form method="dialog">
    <button>閉じる</button>
  </form>
</dialog>

<script>
  openBtn.addEventListener('click', () => {
    myDialog.showModal();
  });
</script>

注目してほしいのが <form method="dialog"> の部分。フォームの送信メソッドに dialog を指定すると、その中のボタンを押しただけでダイアログが閉じます。JSを一行も書かずに「閉じるボタン」が完成する、というのが地味に嬉しいポイントです。

▶ Live Demo — 基本のモーダル

ボタンを押すとダイアログが開きます。Escキー、背景クリック(後述)、閉じるボタン、すべてで閉じることを試してみてください。

ネイティブの <dialog>

Escキーで閉じる、開いたときに中のボタンにフォーカスが当たる、背景の要素にTabで移動できない——これらが何も書かなくても効いています。

ちなみに show() というメソッドもあるのですが、これは「非モーダルダイアログ」になります。背景がクリックできて、フォーカストラップも効きません。モーダルとして使うなら必ず showModal() のほうを呼びましょう。

現場でよく使うパターン:背景クリックで閉じる

<dialog> はEscキーでは閉じてくれますが、背景(バックドロップ)をクリックしたときに閉じる挙動は標準では入っていません。これは現場でほぼ必ず実装することになるので、定番の書き方を覚えておくと便利です。

❌ クリックイベントを子要素にも貼ってしまう
/* これだと中身をクリックしても閉じてしまう */
dialog.addEventListener('click', () => {
  dialog.close();
});
✅ クリック位置がdialog要素自身(=背景)かを判定する
dialog.addEventListener('click', (e) => {
  /* e.target が dialog 自体なら、それは背景部分のクリック */
  if (e.target === dialog) {
    dialog.close();
  }
});

ちょっとしたトリックですが、これは <dialog> の特殊な構造を利用しています。ブラウザは「ダイアログの中身(コンテンツ部分)」と「背景部分」を、見た目上は分けて表示しますが、HTML上はどちらも同じ <dialog> 要素のクリックとして扱われます。なので、クリックされた要素が <dialog> そのものなら背景、子要素なら中身、という判定になるわけです。

▶ Live Demo — 背景クリックで閉じる

開いた後、暗くなった背景部分をクリックしてみてください。中身(白い箱)をクリックしても閉じないのがポイント。

背景クリックで閉じる

このダイアログは、背景のグレー部分をクリックすると閉じます。中身(この白い箱)をクリックしても閉じません。

💡 現場の経験則
確認系のダイアログ(「本当に削除しますか?」など)では、あえて背景クリックで閉じない設計にすることもあります。誤操作で消えてしまうのを防ぐためです。UXの方針に応じて、この挙動を入れる/入れないを判断しましょう。

応用:フォーム入力+戻り値の受け取り

<dialog> の本領が発揮されるのは、入力フォームと組み合わせたとき。<form method="dialog"> の中でボタンに value 属性を持たせると、どのボタンが押されたかを returnValue で受け取れます。「OK/キャンセル」のような選択を、JSのイベント分岐を書かずに表現できる仕組みです。

フォームつきダイアログとreturnValue
<dialog id="nameDialog">
  <form method="dialog">
    <label>お名前
      <input name="username" required>
    </label>
    <button value="cancel">キャンセル</button>
    <button value="ok">送信</button>
  </form>
</dialog>

<script>
  nameDialog.addEventListener('close', () => {
    /* 押されたボタンのvalue が returnValue に入る */
    if (nameDialog.returnValue === 'ok') {
      /* フォームの値を取り出して処理 */
    }
  });
</script>
▶ Live Demo — フォームつきダイアログ

名前を入力して送信ボタンを押すと、下に結果が表示されます。キャンセルだと何も起きません。

お名前を入力

キャンセル側のボタンに formnovalidate を付けているのにも理由があります。これがないと、必須項目が空の状態でキャンセルを押そうとしても、HTMLのバリデーションが先に走って閉じられないからです。「キャンセルだけは無条件で通す」というのは現場でよく必要になる調整です。

開閉アニメーションをCSSだけで付ける

標準の <dialog> は開閉が一瞬でパッと切り替わるので、ちょっと素っ気なく感じることがあります。最近のブラウザでは、CSSの @starting-styletransition-behavior(または transition での allow-discrete)を使うと、開閉アニメーションがCSSだけで書けるようになりました。

開閉トランジションをCSSで
dialog {
  opacity: 0;
  transform: translateY(-20px) scale(0.95);
  transition: opacity 0.25s, transform 0.25s,
              overlay 0.25s allow-discrete,
              display 0.25s allow-discrete;
}
dialog[open] {
  opacity: 1;
  transform: translateY(0) scale(1);
}
/* 開きはじめの状態を指定する */
@starting-style {
  dialog[open] {
    opacity: 0;
    transform: translateY(-20px) scale(0.95);
  }
}

ポイントは @starting-style です。<dialog> は閉じている間 display: none 相当になるので、普通に書くと「最初の状態」がそもそも存在しません。@starting-style はその「表示直前のスタート地点」を定義するための仕組み。これがあるおかげで、フェードイン+スライドのようなアニメーションが成立します。

▶ Live Demo — 開閉アニメーションつき

開くときも閉じるときも、ふわっとフェード+スライドします。背景の透明度もトランジションしているのが分かるはずです。

ふわっと開いて、ふわっと閉じる

CSSだけで開閉トランジションを実装しています。JSは showModal() と close() を呼ぶだけ。

⚠️ 注意
@starting-styleallow-discrete は比較的新しい機能です。サポートしていないブラウザではアニメーションせずに即座に開閉しますが、動作自体は壊れません。プログレッシブエンハンスメントとして安心して導入できます。

もう一歩:背景スタイルとアクセシビリティの仕上げ

<dialog> の背景部分(バックドロップ)には、::backdrop 疑似要素でスタイルを当てられます。色の濃さや、ぼかし(backdrop-filter)を調整して、サイトのトーンに馴染ませると一気に印象が変わります。

バックドロップのスタイリング
dialog::backdrop {
  background: rgba(15, 15, 30, 0.55);
  backdrop-filter: blur(2px);
}

アクセシビリティ面では、デフォルトでロール(role="dialog")が付与されるので明示的な指定は不要です。ただし、ダイアログのタイトルに対応する見出しがある場合は aria-labelledby でその見出しを参照しておくと、スクリーンリーダーの読み上げが自然になります。説明文に対しては aria-describedby です。

アクセシビリティの仕上げ
<dialog aria-labelledby="dlgTitle" aria-describedby="dlgDesc">
  <h2 id="dlgTitle">確認</h2>
  <p id="dlgDesc">この操作は元に戻せません。</p>
  <!-- ボタン群 -->
</dialog>

もう一つ、現場で覚えておきたいのが closedby 属性です。新しめの仕様で、closedby="any" を付けると背景クリックでも閉じる挙動が標準で入ります。対応ブラウザではJSの背景クリック処理が不要になるのですが、まだ対応が広がっている途中なので、当面は前述のJSパターンを併用しておくのが安全です。

まとめ

  • <dialog> 要素は、フォーカス管理・Escキー対応・他要素の不活性化など、モーダルに必要な振る舞いをブラウザネイティブで提供してくれる。
  • モーダルとして使う場合は必ず showModal() を呼ぶこと。show() は非モーダル用なのでフォーカストラップが効かない。
  • <form method="dialog"> を使うと、JSなしで「閉じるボタン」が作れる上、ボタンの valuereturnValue として受け取れる。
  • 背景クリックで閉じる挙動は、e.target === dialog の判定を使った数行のJSで実装できる。
  • キャンセル系のボタンには formnovalidate を付けて、必須項目のバリデーションを回避するのがポイント。
  • 開閉アニメーションは @starting-styletransitionallow-discrete を使えばCSSだけで実装可能。
  • 背景は ::backdrop 疑似要素でスタイリング、見出しと説明文は aria-labelledby / aria-describedby で関連付けると、よりアクセシブルになる。