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

カテゴリ:
HTML
投稿日:

広告

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-polyfill
npm install dialog-closedby-polyfill
main.js
import "invokers-polyfill";
import "dialog-closedby-polyfill";

CDN を利用する場合

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 を指定する。
command 属性 / commandfor 属性の設定
<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 を導入することで利用できます。

GitHub - keithamus/invokers-polyfill

Contribute to keithamus/invokers-polyfill development by creating an account on GitHub.

github.com

closedby属性

こちらも同時期にHTML標準となった、ポップオーバー API のような Light dismiss 機能を <dialog> 要素にも適用するものです。

2025年5月現在、規定されているアクションは次のとおりです。

  • closedby="any" : ダイアログの外側をクリックするか ESC キーを押すと <dialog> が閉じます。popover="auto" の動作に似ています。
  • closedby="closerequest" : ESC キーを押すと <dialog> が閉じます。closedby 属性が指定されていない場合の挙動に似ています。
  • closedby="none" : ESC キーを押しても <dialog> は閉じません。
closedby valueESC keyBackdrop clickclose() method
"any"
"closerequest"
"none"

こちらも使い方は至極簡単で、<dialog> 要素に closedby 属性を指定するだけです。

closedby="any" を指定すると従来の Esc キーでの閉じる動作に加えて、モーダルの外側(backdrop)をクリック時にも閉じる動作を加えることができます。従来のようにJavaScriptでそれ用の処理を書く必要はありません。

closedby 属性の設定
<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 を導入することで利用できます。

GitHub - tak-dcxi/dialog-closedby-polyfill: A polyfill for the HTMLDialogElement closedBy attribute, providing control over how modal dialogs can be dismissed.

A polyfill for the HTMLDialogElement closedBy attribute, providing control over how modal dialogs can be dismissed. - tak-dcxi/dialog-closedby-polyfil...

github.com

従来の実装との比較

JavaScriptの showModal() close() メソッドを使用する実装と比較した際のメリットは次のとおりです。

  • JavaScript を書かずとも HTML 標準で利用できる。
  • HTMLに属性を設定するだけなので実装コストが低い
  • JavaScriptを使わないので、JavaScript が無効になっている場合でも開閉が可能になる。(command 属性 / commandfor 属性がサポートされているのが条件)
  • command 属性 / commandfor 属性は button 要素のみ適用できる。ボタンを divspan で作成する罪は今更語ることは無いが、そういった実装を自然と防ぐことができる。

デメリットは記事執筆時点で全コアブラウザでサポートされていないため、非サポート環境では 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 との兼ね合いもあり、:rootoverflow: 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: hiddenoverflow: scroll、または overflow: auto を指定すると、その要素自体が新しいスクロールコンテナとなります。

もし、position: sticky を使いたい要素の親要素のいずれかにこれらの overflow プロパティが指定されていると、その sticky 要素はウィンドウ全体ではなく、その新しく作られた親のスクロールコンテナを基準に動作しようとします。多くの場合、これにより position: sticky が期待通りに機能しなくなります。

しかし、今回のように :root に対して overflow: hidden を指定する場合は、この問題は起こりにくいです。CSSの仕様では、ルート要素に設定された overflow プロパティの効果は、ブラウザの表示領域全体(ビューポート)に転送されることになっています。このとき、ルート要素自体が新たなスクロールコンテナとして扱われるわけではありません。

そのため、body に対して overflow: hidden を指定すると子要素の position: sticky は動作しなくなりますが、ルート要素に overflow: hidden を指定してもビューポートを基準していることには変わりがないため、問題は起きません。

スクロールバーの切り替わりによるガタツキの対策

:rootscrollbar-gutter: stable を指定することでスクロールバーの有無に関わらずガターを確保します。

:root {
scrollbar-gutter: stable;
}

こうすることで、モーダルが開いた際に背面コンテンツがスクロールバーの切り替わりでガタつく現象を回避します。

また、常時スクロールバー分のガターを設けることによって、モーダルの表示に関係なくスクロールバーの切り替わりで起こり得るレイアウトシフトの防止にもなります。

少し前までは position: fixed と組み合わせた際に挙動に不具合がありましたが、現在は修正されています。

フォーカスの挙動

1年前の記事で実装例を紹介した「モーダルウィンドウを閉じた時にモーダルウィンドウが開く前にフォーカスされていた要素(開くトリガー)にフォーカスが移る」に関しては、ブラウザ標準で focus-restore を行うので不要です。

また、showModal された場合は背面コンテンツが inert されるので、フォーカストラップなどの実装も必要ありません。

むしろ自前のフォーカストラップはブラウザのツールバーなどにフォーカスを移せなくなるため、アクセシビリティを悪化させるという指摘もあります。

The current state of modal dialog accessibility - TPGi

Modal dialogs continue to be troublesome UI components all across the web. Putting aside the fact they are often misused and thrust on users in a mann...

www.tpgi.com

モーダル内の見出しの扱いについて

仕様書のサンプルコードのように 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> にはアクセシブルな名前を持つことが求めらるため、h1id と紐づけた aria-labelledby 属性を <dialog> 要素に指定するようにします。

開閉時のアニメーション

<dialog> の開閉時にはアニメーションが求められるケースがあります。

かつては display: nonetransition が効かないため、アニメーションを実行し、非表示までの猶予を設けるためにJavaScriptでの制御が必要でしたが、現在では displaytransition を適用できるため CSS オンリーで問題ありません。

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;
}
}

displaytransition するために、離散プロパティの遷移を許可する transition-behavior: allow-discrete を指定します。また、showModal した <dialog> や ポップオーバー API のようなトップレイヤーとなる要素に関しては overlaytransition-property に指定する必要があるので忘れないようにしてください。

また、display: none が指定されている要素に transition を指定する場合、そのままでは transition は動作しません。transition開始時のスタイルを指定できる @starting-style アットルールを用いて開始時のスタイルを明示することで、display: none からの transition を発火させることができるようになります。

注意点としては、記事執筆時点では display に対する transition-behavior: allow-discreteFirefox はサポートしていません

Firefox では閉じた際のアニメーションが発火しないだけで、モーダルの挙動へ悪影響を及ぼすわけではないので、そのうちサポートされることを期待しつつプログレッシブ・エンハンスメントで良いかと思います。

一応、Safariで表示に不具合があるため position: fixed inset: 0 は明示しておくと良いです。余談ですが、モーダルの高さ調整に 100vh を指定している人が多いですが、inset: 0 で十分です。記述量が減るだけではなく、iOS の X の Webブラウザ では 100vh だとアドレスバー分見切れる(100svhみたいな挙動になる)ので、Xから遷移した時に見栄えが悪くなります。


このように実装することで Polyfill 以外の JavaScript を書かずともモーダルウィンドウの実装が可能となります。

個人的には Web 標準で利用できるもの(仕様としてマージされているもの)に関しては Polyfill を導入しつつ使用したほうが良いと考えています。

複雑な Web アプリケーションの場合はそうではないかもしれませんが、一般的な Web 制作であれば試してみるのはいかがでしょうか。

折りたたみメニュー