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 属性 / closedby 属性 はまだサポートが行き渡っていませんが、Polyfill を利用することで <dialog> 要素がサポートされている全てのブラウザで利用することができます。
npm install する場合
npm install する場合は main.js などで import する必要があります。
npm install invokers-polyfillnpm install dialog-closedby-polyfillimport "invokers-polyfill";import "dialog-closedby-polyfill";CDN を利用する場合
以下の <script> 要素を <head> の中で読み込んでください。
<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 のアプリ内ブラウザでは 100vh だとアドレスバー分見切れる( 100svh みたいな挙動になる)ため、X などの SNS から遷移した時に見栄えが悪くなります。
このように実装することで Polyfill 以外の JavaScript を書かずともモーダルウィンドウの実装が可能となります。
個人的には Web 標準で利用できるもの(仕様としてマージされているもの)に関しては Polyfill を導入しつつ使用したほうが良いと考えています。
複雑な Web アプリケーションの場合はそうではないかもしれませんが、一般的な Web 制作であれば試してみるのはいかがでしょうか。
