Tech

意外とややこしい!モーダルとしてのdialog要素とLight dismissについて

公開

<dialog> 要素というものを知った時は、ついにモーダルのあれやこれやを一気に解決してくれる救世主が現れたかと思ったものですが、実際はなかなかこれまでの実装に四苦八苦してきたクセもあり自前の <div> を使ってばかりです(それでも昔と比べたら同時期に策定された inert 属性などかなり恩恵は受けているのですが…… )。

食わず嫌いもよくないのでdialog要素の持つ性質や構造、実際にどう使うかを簡易にまとめてみようと思います。そもそもタイトルのLight dismissとはなんぞや(自分も直近初めて知りました)。

とりあえずモーダルとして開く

<button type="button" id="modalBtn">モーダルを開く</button>

<dialog id="modal"></dialog>

<dialog> で囲まれた要素はページが読み込まれた時点では非表示になっているため、表示させるためのJSを書く必要があります。

開き方はいろいろあるのですが、ここではdialog要素をモーダルとして扱うことだけを考えるため showModal() メソッド一択です。名前もわかりやすくていいですね。

const modal = document.querySelector('modal');
const modalBtn = document.querySelector('modalBtn');

if ( modal && modalBtn ) {
  modalBtn.addEventListener('click', (e) => {
    modal.showModal();
  });
}

何もスタイルを当てず、showModal() によって表示させただけだとこのような感じです。(自動で当てられている余白やボーダーなどはブラウザ固有ですが)既に画面の中央に浮かんでくれていますね。裏側が白いのでわかりづらいですが背景としての疑似要素 ::backdrop も付いています。

ここから中身を実装していくことになるのですが、先ほどの通り初期では非表示 display: none; なので、「開発中には開いておきたい」といった希望が生まれると思います。が……単純にHTMLコード上で open 属性を設けてしまうだけだと、以下のように画面中央でもなく・背景も存在しない状態の表示になってしまいます。

これは必ずしも「dialog = モーダル」というわけではなく、ポップアップ(非モーダル)としても扱えてしまうためで、「モーダル化」は open 属性の付け外しだけで制御しているわけではないことがわかります。

つまり先ほどボタンクリック時に叩いた showModal() メソッド自体が以下のような「モーダルあるある」な実装をおおむね一気に解決してくれているわけです。

  • <dialog> 要素をトップ(最上位)レイヤーに配置して必ず前面に表示する
  • dialog::backdrop 疑似要素を作成しモーダルの背景をダイアログのすぐ後ろに作成
  • dialog:modal や :-internal-dialog-in-top-layer (※Chromeのみ)といったユーザ不可侵の状態クラスを作成し、中央寄せなどのデフォルトスタイルを当てる
  • <dialog> にopen 属性を付与( dialog[open] で開いた状態を指定できるようになる)
  • ダイアログ以外の要素を全て inert (不活性)状態にし、クリック・Tabフォーカス等を無効化する(※モーダル以外を全てブロックする処理のため実際に inert 属性は付与されない)
  • その他、フォーカスを内部に移動・Escキーで閉じられるようになどアクセシビリティ関連の処理

特に1つめの「トップ(最上位)レイヤー」化については比較的最近の概念で、既存の要素が存在するステージとは異なる「必ず前面に来る」レイヤーに配置してくれます。

これを利用することでもう異常にデカい z-index: 10000; などを指定する必要はなくなりますし……2つ目の ::backdrop 疑似要素についてもこちらの最上位レイヤー(dialog)のすぐ下に配置されるため、ビューポートすべてを覆い隠すことを前提の背景要素として想定されています。

https://developer.mozilla.org/ja/docs/Glossary/Top_layer

Chrome などの一部のブラウザーでは、最上位レイヤーに配置された要素を特別な DOM ツリー項目の中に表示します。例えば、以下のようになります。

Chromeの開発者ツール上では最上位レイヤーの内容を #top-layer として別個にも記載してくれるので見やすいです。

ただ、繰り返すようにopen属性を付けただけではこの最上位レイヤーへの移設する処理を含む諸々が働かないため、必ず表示のためには showModal() メソッドを通す必要があるのは注意しましょう。

またモーダルを開閉するための配慮は行き届いていますが、範囲外の配慮……たとえば 「モーダル裏コンテンツのスクロール制御」などは行われません 。PCだけなら body:has(dialog[open]) など先ほどの open 属性を活用することでCSSもラクに書けますが、結局iOS Safariとかいうブラウザには専用の実装で「ご配慮」する必要があります。 つっかえ

余談:HTMLだけでdialogを操作する方法 ※未来向け

どうしてもdialogの操作をHTMLだけで完結したい理想を叶えるために、「Invoker Commands API」(呼び出しコマンド API)というものが2025年ごろから策定されました。使い方としては該当の操作ボタンに commandfor (操作対象)と command (操作内容)の属性セットを付与します。

<button commandfor="modal" command="show-modal">開く</button>

<dialog id="modal">
  <button commandfor="modal" command="close">閉じる</button>
</dialog>

これだけで先ほどの showModal() と全く同じ処理を動かすことが出来ます。わかりやすい。

またこの例では「閉じる」 close() ボタンをモーダル内部に配置していますが、配置が変われど id と commandfor の対応関係が変わっていなければどこからでも使えます。便利。

ただし問題はまともに使えるようになった= Safariの対応バージョンが「2025年12月以降」 ということで、まだまだこういった動的コンテンツにはJSを書く必要がありそうです。ただし、HTMLの範囲で色々できそうな未来だけは見えているので、希望を持って(2〜3年後くらいに)思い出しましょう。

https://developer.mozilla.org/ja/docs/Web/API/Invoker_Commands_API

モーダルを(外側から)閉じられたらよかった

開く用のメソッドは showModal() ですが、閉じる用のメソッドは close() になります。 ここはモーダルだとか非モーダルだとかに関わらず「dialog要素を閉じる=非表示化」だけのメソッドで、決して closeModal()とかではないので注意しましょう。

しかし、いわゆる「モーダルウィンドウ」といえばウィンドウ外の背景をクリック/タップすると自然と閉じられるものと思っていませんでしょうか。

このような外側の背景部分をクリック/タップして 「簡単に閉じる」ための実装を、近頃「Light dismiss」 (「簡単な解除」?)と呼ぶようです。日本語圏では2023年頃からの言及しか見つからないので、あまり広まっていない表現のようですが、操作や制御の概念自体は以前からよくあるものですね。

Light〜というだけあって実装も簡単ッ!簡単ッ!かというと実はそんなことはなく、モーダルはわざわざ「閉じろ!」と念じながら泣く泣く実装をしてあげないと都合よく閉じられることはありません。そもそも「ユーザに閉じさせたくない」モーダルが求められる場面も多々ありますし……。

<dialog> においても例外ではなく、モーダルとして開くとついてくる ::backdrop はデフォルトではクリック/タップ不能なただの背景です。そのため今も昔はモーダルウィンドウ内/外のクリック判定をJSで判別したり、そもそも(先ほどの showModal() を使わない)非モーダルであるポップオーバー popover 属性を代わりに用いることで解決していました。

<button popovertarget="popup">表示する</button>

<dialog id="popup" popover>
  <button popovertarget="popup" popovertargetaction="hide">閉じる</button>
</div>

※ <button> 側の popovertargetaction="show” 属性は書かなくてもOK、 <dialog> 側の popover="auto" は popover に省略可能のためそちらで反映しています。

先ほどの余談でHTMLのみで完結する方法について触れましたが、popover | popovertarget / popovertargetaction の組を使ってもなんと HTMLだけでポップアップ機構の実装が可能 です。ブラウザ対応状況も2024年4月(Firefox)以降〜と比較的実用の範囲内です。

ただしこれは「Popover API」(ポップオーバーAPI)と呼ばれるポップアップ(非モーダル)向けの処理であり、モーダルないし <dialog> のためだけに作られたものではありません。そのため背景コンテンツのinert(非活性)化やフォーカスの移動処理などといったモーダルだけに求められる追加処理が効かないため、あくまで実装上の「都合が良い」ためだけに活用されていたんじゃないか……?と思われます(個人の感想です)。

そのため、<dialog> をどのように扱う場合でも dialog::backdrop 部分をクリック/タップして 「閉じる」処理制御を行うための closedby 属性 が設けられています。

https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/dialog#closedby

<dialog> 要素を閉じるために使用できるユーザー操作の種類を指定します。この属性は、ダイアログが閉じられる可能性のある 3 つの方法を区別します。

  • 「簡単な解除のユーザー操作」。ユーザーがダイアログの外側をクリックまたはタップすると、<dialog> が閉じられます。これは、「自動」状態のポップオーバーにおける「簡単な解除」動作 と同等です。
  • 「プラットフォーム固有のユーザー操作」。例えば、デスクトッププラットフォームでは Esc キーを押す操作、モバイルプラットフォームでは「戻る」または「閉じる」ジェスチャーなど。
  • 開発者が指定した機構(例:<button> に click ハンドラーを設定し、そこで HTMLDialogElement.close() を呼び出したり、 <form> を送信したりする。

<dialog> 要素に有効な closedby 値が指定されていない場合、

  • showModal() を使用して開かれた場合、値が "closerequest" であるかのように動作します。
  • それ以外の場合、値が "none" であるかのように動作します。

これらを読む限り、あくまで「モーダル」としては徹底して closerequest = 「プラットフォーム固有のユーザー操作」以上の故意にしか閉じさせない状態がデフォルトになっています。(逆に言うと、簡単に閉じられてしまうものは「モーダル」と呼べないよう想定・設計されているではとも言えますが……)

そのためこちらの属性を用いる場面はほとんどの場合 closedby=any になります。

<button type="button" id="modalBtn">モーダルを開く</button>

<dialog id="modal" closedby="any"></dialog>

これだけで ::backdrop 部分でモーダルを閉じる「Light dismiss」が有効化できます。簡単ッ!

ただし悲しいことに2026年9月末時点において、 closedby 属性はモダンブラウザではSafari on iOSのみ未対応です。言わないよねえェッ(Appleはモーダルのこと嫌いなんでしょうか)

https://developer.mozilla.org/ja/docs/Web/API/HTMLDialogElement/closedBy

ただしSafariをナシにしても2025年以降の限定的環境に留まるので、背景で閉じたければまだまだ モーダルのフリしたポップアップの方が実用的 な状況みたいです。わしも使いたかったなァ……。

Safari Technology Preview(プレビュー版)における策定自体は進んでいるようなので、 <dialog> を使ってみる場合もモーダルの実装に関しては気長にこれまでと似たようなJSを書きながら待ちましょう。強くなりたい……ねッ。