JavaScriptを書かない2025年のモーダルの実装方法

広告
2025年のモーダルの実装方法のメモ書き。
<dialog>
要素の利用を前提とします。 Web アプリケーションで UI ライブラリを利用する場合は話が変わってきます。
1年前にdialog要素を使用したモーダルウィンドウの実装例を投稿しましたが、大幅にアップデートされているため、忘れても構いません。
結論
<dialog>
要素の開閉はcommand
属性を使用する<dialog>
要素の外側をクリックした際に閉じる Light dismiss 機能はclosedby
属性を使用するcommand
属性とclosedby
属性は Polyfill を使用する- 開閉時のアニメーション、背面のスクロール抑制、スクロールバーの有無によるガタツキの防止は CSS で行う
方針としては極力JavaScriptを書かないことを目指します。
次のようにHTMLを書くことで<dialog>
要素の開閉および Light dismiss 機能が有効になります。showModal()
や close()
するために JavaScript を書く必要はありません。
<button type="button" commandfor="my-dialog" command="show-modal"> モーダルを開く</button>
<dialog id="my-dialog" closedby="any" aria-labelledby="my-dialog-heading" autofocus> <h1 id="my-dialog-heading">Heading</h1> <p>Content</p> <button type="button" commandfor="my-dialog" command="close">閉じる</button></dialog>
command 属性 / commandfor 属性はまだサポートが行き渡っていませんが、Polyfill を利用することで <dialog>
要素がサポートされている全てのブラウザで利用することができます。
npm install する場合
npm install
する場合は main.js
などで import
する必要があります。
npm install invokers-polyfillnpm install dialog-closedby-polyfill
import "invokers-polyfill";import "dialog-closedby-polyfill";
CDN を利用する場合
<script type="module" src="https://unpkg.com/invokers-polyfill@latest/invoker.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/dialog-closedby-polyfill@latest/index.min.js"></script>
command 属性 / commandfor 属性
今年2月にHTMLの仕様にマージされた、 button
要素に付与することで JavaScript を直接書かずとも指定したターゲット要素に対して組み込みの動作を宣言的に実行させることができる新しい HTML 属性です。
2025年5月現在、規定されているアクションは次のとおりです。
command="show-modal"
: モーダルUIを開くcommand="close"
: モーダルUIを閉じるcommand="toggle-popover"
: ポップオーバーUIの開閉command="show-popover"
: ポップオーバーUIの展開command="hide-popover"
: ポップオーバーUIの収納command="request-close"
: requestClose()の機能を提供(Chrome 139でリリース予定)
従来の popovertarget
/ popovertargetaction
属性を置き換えて汎用化・拡張された仕組みなのでポップオーバーAPIでも利用できます。
command
属性 / commandfor
属性の使い方は至極簡単です。
- 開くトリガーに
<dialog>
のid
と紐づけたcommandfor
属性を指定し、command
属性にshow-modal
を指定する。 - 閉じるボタンに
<dialog>
のid
と紐づけたcommandfor
属性を指定し、command
属性にclose
を指定する。
<button type="button" commandfor="my-dialog" command="show-modal"> Open Modal</button>
<dialog id="my-dialog"> <h1>Example</h1> <p>Content</p> <button type="button" commandfor="my-dialog" command="close"> Close </button></dialog>
Google Chrome と Microsoft Edge は 135 からサポートされています。Safari と Firefox はサポートされていませんが、 Polyfill を導入することで利用できます。
closedby属性
こちらも同時期にHTML標準となった、ポップオーバー API のような Light dismiss 機能を <dialog>
要素にも適用するものです。
2025年5月現在、規定されているアクションは次のとおりです。
closedby="any"
: ダイアログの外側をクリックするか ESC キーを押すと<dialog>
が閉じます。popover="auto"
の動作に似ています。closedby="closerequest"
: ESC キーを押すと<dialog>
が閉じます。closedby
属性が指定されていない場合の挙動に似ています。closedby="none"
: ESC キーを押しても<dialog>
は閉じません。
closedby value | ESC key | Backdrop click | close() method |
---|---|---|---|
"any" | ✅ | ✅ | ✅ |
"closerequest" | ✅ | ❌ | ✅ |
"none" | ❌ | ❌ | ✅ |
こちらも使い方は至極簡単で、<dialog>
要素に closedby
属性を指定するだけです。
closedby="any"
を指定すると従来の Esc キーでの閉じる動作に加えて、モーダルの外側(backdrop)をクリック時にも閉じる動作を加えることができます。従来のようにJavaScriptでそれ用の処理を書く必要はありません。
<dialog id="my-dialog" closedby="any"> <h1>Example</h1> <p>Content</p> <button type="button" commandfor="my-dialog" command="close"> Close </button></dialog>
Google Chrome と Microsoft Edge は 134 からサポートされています。こちらも Safari と Firefox はサポートされていませんが、 Polyfill を導入することで利用できます。
従来の実装との比較
JavaScriptの showModal()
close()
メソッドを使用する実装と比較した際のメリットは次のとおりです。
- JavaScript を書かずとも HTML 標準で利用できる。
- HTMLに属性を設定するだけなので実装コストが低い
- JavaScriptを使わないので、JavaScript が無効になっている場合でも開閉が可能になる。(
command
属性 /commandfor
属性がサポートされているのが条件) command
属性 /commandfor
属性はbutton
要素のみ適用できる。ボタンをdiv
やspan
で作成する罪は今更語ることは無いが、そういった実装を自然と防ぐことができる。
デメリットは記事執筆時点で全コアブラウザでサポートされていないため、非サポート環境では Polyfill 依存となることです。
実装デモ
背面のスクロールを抑制する
2025年5月現在も iOS で完全にスクロールを無効にすることはできませんが、CSSで背面スクロールを抑制するのが手っ取り早いのでそれを利用します。
:root:has(:modal) { overflow: hidden;}
1年前に投稿した記事でもそうですが、多くの情報源では body:has(dialog[open])
という指定が紹介されています。ですが、上記のように :root:has(:modal)
という指定のほうが良いかと考えます。
スクロールを止める対象は :root 要素が良い
背景のスクロールを止めるには、Webページ全体の表示領域を管理している要素に overflow: hidden
を指定します。
この「ページ全体の表示領域を管理している要素」は JavaScript では document.scrollingElement
で取得ができ、通常は html
要素を返します。後述する position: sticky
との兼ね合いもあり、:root
に overflow: hidden
を適用するのが良いでしょう。
モーダルなダイアログだけを対象にする
<dialog>
要素には、show()
メソッドを使った背景の操作をブロックしない「非モーダル」という表示方法もあります。ポップオーバー API の登場によって使いどきが減ったものの、非モーダルで表示された際に背面スクロールが抑制されるのは筋が悪いです。
従来の body:has(dialog[open])
という指定では、非モーダルダイアログも対象に含まれてしまいます。
そこで参照するのが :modal
という疑似クラスです。これは「ユーザーがその要素を操作している間、他の全ての要素との操作をブロックする状態にある要素」だけを選択します。つまり、本当に操作をブロックする必要があるモーダル(showModal()
メソッドで開いた <dialog>
など)だけを対象にできます。
:modal
疑似クラスは、動画などの特定の要素をrequestFullscreen()
APIを使用してフルスクリーンで表示する際にも適用されます。そのため、:root:has(:modal)
と指定しておけば、フルスクリーン表示の際にも同様に背景の制御ができます。
<dialog>
に関する議論では modeless ではなく non-modal と表現するのが一般的との指摘を頂いたため、「モードレス」→「非モーダル」へ表記を修正しました。
overflow: clip ではダメなのか?
スクロールを制御する CSS には overflow: hidden
の他に overflow: clip
というものもあります。
普段は意図しないスクロールコンテナやスタックコンテキストの出現を防げるため、 overflow: clip
を優先して使いますが、今回は overflow: hidden
を使用します。
これは、Firefox でルート要素に対して overflow: clip
を使った場合にうまくスクロール抑制が機能しないためです。
position: sticky との関係について
「overflow: hidden
を使うと position: sticky
が効かなくなる」という話をよく聞きます。確かに body:has(:modal)
でスクロール抑制を行うと position: sticky
が効かなくなりますが、これには明確な理由があります。
position: sticky
は、最も近い親の「スクロールコンテナ」(スクロールする範囲を持つ要素)を基準にして固定される仕組みです。 ある要素に overflow: hidden
、overflow: scroll
、または overflow: auto
を指定すると、その要素自体が新しいスクロールコンテナとなります。
もし、position: sticky
を使いたい要素の親要素のいずれかにこれらの overflow
プロパティが指定されていると、その sticky
要素はウィンドウ全体ではなく、その新しく作られた親のスクロールコンテナを基準に動作しようとします。多くの場合、これにより position: sticky
が期待通りに機能しなくなります。
しかし、今回のように :root
に対して overflow: hidden
を指定する場合は、この問題は起こりにくいです。CSSの仕様では、ルート要素に設定された overflow
プロパティの効果は、ブラウザの表示領域全体(ビューポート)に転送されることになっています。このとき、ルート要素自体が新たなスクロールコンテナとして扱われるわけではありません。
そのため、body
に対して overflow: hidden
を指定すると子要素の position: sticky
は動作しなくなりますが、ルート要素に overflow: hidden
を指定してもビューポートを基準していることには変わりがないため、問題は起きません。
スクロールバーの切り替わりによるガタツキの対策
:root
に scrollbar-gutter: stable
を指定することでスクロールバーの有無に関わらずガターを確保します。
:root { scrollbar-gutter: stable;}
こうすることで、モーダルが開いた際に背面コンテンツがスクロールバーの切り替わりでガタつく現象を回避します。
また、常時スクロールバー分のガターを設けることによって、モーダルの表示に関係なくスクロールバーの切り替わりで起こり得るレイアウトシフトの防止にもなります。
少し前までは position: fixed
と組み合わせた際に挙動に不具合がありましたが、現在は修正されています。
フォーカスの挙動
1年前の記事で実装例を紹介した「モーダルウィンドウを閉じた時にモーダルウィンドウが開く前にフォーカスされていた要素(開くトリガー)にフォーカスが移る」に関しては、ブラウザ標準で focus-restore を行うので不要です。
また、showModal
された場合は背面コンテンツが inert
されるので、フォーカストラップなどの実装も必要ありません。
むしろ自前のフォーカストラップはブラウザのツールバーなどにフォーカスを移せなくなるため、アクセシビリティを悪化させるという指摘もあります。
モーダル内の見出しの扱いについて
仕様書のサンプルコードのように h1
始まりとします。
<dialog id="my-dialog" closedby="any" aria-labelledby="my-dialog-heading" autofocus> <h1 id="my-dialog-heading">Heading</h1> <p>Content</p> <button type="button" commandfor="my-dialog" command="close">Close</button></dialog>
モーダルが開いている際は背面が inert
されるため、背面コンテンツの見出しはツリーに存在しないことになります。モーダルに関しては一つの「ページ」として捉え、h1
始まりとするようにしておきます。
また、<dialog>
にはアクセシブルな名前を持つことが求めらるため、h1
の id
と紐づけた aria-labelledby
属性を <dialog>
要素に指定するようにします。
開閉時のアニメーション
<dialog>
の開閉時にはアニメーションが求められるケースがあります。
かつては display: none
に transition
が効かないため、アニメーションを実行し、非表示までの猶予を設けるためにJavaScriptでの制御が必要でしたが、現在では display
に transition
を適用できるため CSS オンリーで問題ありません。
dialog { position: fixed; /* Safariで表示に不具合があるので明示する */ inset: 0; /* Safariで表示に不具合があるので明示する */ overscroll-behavior-block: contain; transition-duration: 300ms; transition-property: display, overlay, opacity; transition-timing-function: ease-out; transition-behavior: allow-discrete;
&::backdrop { background-color: oklch(from black l c h / 50%); backdrop-filter: blur(4px); transition-duration: inherit; transition-property: opacity; transition-timing-function: inherit; }
&:modal, &:modal::backdrop { @starting-style { opacity: 0; } }
&:not(:modal), &:not(:modal)::backdrop { opacity: 0; }}
display
を transition
するために、離散プロパティの遷移を許可する transition-behavior: allow-discrete
を指定します。また、showModal
した <dialog>
や ポップオーバー API のようなトップレイヤーとなる要素に関しては overlay
も transition-property
に指定する必要があるので忘れないようにしてください。
また、display: none
が指定されている要素に transition
を指定する場合、そのままでは transition
は動作しません。transition
開始時のスタイルを指定できる @starting-style
アットルールを用いて開始時のスタイルを明示することで、display: none
からの transition
を発火させることができるようになります。
注意点としては、記事執筆時点では display
に対する transition-behavior: allow-discrete
を Firefox はサポートしていません。
Firefox では閉じた際のアニメーションが発火しないだけで、モーダルの挙動へ悪影響を及ぼすわけではないので、そのうちサポートされることを期待しつつプログレッシブ・エンハンスメントで良いかと思います。
一応、Safariで表示に不具合があるため position: fixed
inset: 0
は明示しておくと良いです。余談ですが、モーダルの高さ調整に 100vh
を指定している人が多いですが、inset: 0
で十分です。記述量が減るだけではなく、iOS の X の Webブラウザ では 100vh
だとアドレスバー分見切れる(100svh
みたいな挙動になる)ので、Xから遷移した時に見栄えが悪くなります。
このように実装することで Polyfill 以外の JavaScript を書かずともモーダルウィンドウの実装が可能となります。
個人的には Web 標準で利用できるもの(仕様としてマージされているもの)に関しては Polyfill を導入しつつ使用したほうが良いと考えています。
複雑な Web アプリケーションの場合はそうではないかもしれませんが、一般的な Web 制作であれば試してみるのはいかがでしょうか。