モーダルの実装は何かと大変です。便利なライブラリもたくさん出ていますが、今回は<dialog>
を使ったモーダルの実装を紹介します。
デモはこちらです。
https://codepen.io/mtoutside/pen/jOXbwPz
See the Pen <dialog> demo with fadeAnimation and scroll lock by mtoutside (@mtoutside) on CodePen.
HTML
<dialog class="dialog js-dialog is-hidden">
<div class="dialog__inner js-dialog__inner">
<h3 class="dialog__title">dialog内タイトル</h3>
<p class="dialog__text">dialog内のテキスト</p>
<p class="dialog__text">dialog内のテキスト</p>
<button class="btn btn--close js-btn--close" type="button">閉じる</button>
</div><!-- /.dialog_inner -->
</dialog>
<button class="btn btn--open js-btn--open" type="button">モーダル</button>
css
cssはこちら
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.btn {
cursor: pointer;
}
dialog::backdrop {
background: rgba(94, 94, 94, 0.5);
backdrop-filter: blur(4px);
}
body.is-modal {
overflow: hidden;
}
dialog.is-hidden {
display: none;
}
.dialog {
display: block;
position: fixed;
top: 0;
bottom: 0;
left: 0;
transform-origin: center;
padding: 0;
min-width: 600px;
margin: auto;
animation-name: fadeOut;
animation-fill-mode: forwards;
animation-duration: 200ms;
animation-timing-function: ease-out;
}
.dialog[open] {
animation-name: fadeIn;
animation-fill-mode: forwards;
animation-duration: 400ms;
animation-timing-function: ease-out;
::backdrop {
animation-name: fadeIn;
animation-fill-mode: forwards;
animation-duration: 200ms;
animation-timing-function: ease-out;
}
}
.dialog__inner {
padding: 16px;
}
.dialog__text {
margin: 500px 0;
}
モーダルオープン時の背景は::backdrop
を使って設定します。
JavaScript
classを使用しています。
/**
* モーダルダイアログを管理するためのユーティリティクラスです。
*/
class ModalManager {
/**
* ModalManager のインスタンスを作成します。
*
* @param {HTMLElement} modal - モーダルダイアログ要素。
* @param {HTMLElement} modalInner - モーダルの内部コンテンツコンテナ要素。
* @param {HTMLElement} closeTrigger - モーダルを閉じるための要素。
* @param {HTMLElement} openTrigger - モーダルを開くための要素。
*/
constructor(modal, modalInner, closeTrigger, openTrigger) {
this.modal = modal;
this.modalInner = modalInner;
this.openTrigger = openTrigger;
this.closeTrigger = closeTrigger;
this.body = document.body;
this.MODAL_CLASS = "is-modal";
this.HIDDEN_CLASS = "is-hidden";
this.scrollbarWidth = window.innerWidth - document.body.scrollWidth;
}
/**
* 指定されたダイアログ内のすべてのアニメーションが完了するのを待ちます。
*
* @param {HTMLDialogElement} dialog - アニメーションを含むダイアログ要素。
* @returns {Promise<Array<PromiseSettledResult>>} 各アニメーションの完了状態ごとに解決される配列を返す Promise です。
*/
async waitDialogAnimation(dialog) {
return Promise.allSettled(
Array.from(dialog.getAnimations()).map(animation => animation.finished)
);
}
// モーダルを開く
openModal() {
this.body.classList.add(this.MODAL_CLASS);
this.modal.classList.remove(this.HIDDEN_CLASS);
this.body.style.paddingRight = this.scrollbarWidth + "px";
this.modal.showModal();
this.modal.scrollTo(0, 0);
}
// モーダルを閉じる
closeModal() {
this.body.classList.remove(this.MODAL_CLASS);
this.modal.close();
}
// キャンセル(esc押下)されたとき
onModalCancel() {
this.body.classList.remove(this.MODAL_CLASS);
}
// モーダルの背景を押されたとき
onModalClick(e) {
if (!e.target.closest(this.modalInner)) {
this.body.classList.remove(this.MODAL_CLASS);
this.modal.close();
}
}
// modalのcloseイベント
async onModalClose() {
this.body.style.paddingRight = "";
await this.waitDialogAnimation(this.modal);
this.modal.classList.add(this.HIDDEN_CLASS);
}
// 初期化 イベントリスナー設定
init() {
this.openTrigger.addEventListener("click", () => this.openModal());
this.closeTrigger.addEventListener("click", () => this.closeModal());
this.modal.addEventListener("cancel", () => this.onModalCancel());
this.modal.addEventListener("click", (e) => this.onModalClick(e));
this.modal.addEventListener("close", () => this.onModalClose());
}
}
// モーダルの設定
document.addEventListener("DOMContentLoaded", () => {
const modal = document.querySelector(".js-dialog");
const modalInner = ".dialog__inner";
const openTrigger = document.querySelector(".js-btn--open");
const closeTrigger = document.querySelector(".js-btn--close");
const modalManager = new ModalManager(modal, modalInner, closeTrigger, openTrigger);
modalManager.init();
});
モーダルを開く
// モーダルを開く
openModal() {
this.body.classList.add(this.MODAL_CLASS);
this.modal.classList.remove(this.HIDDEN_CLASS);
this.body.style.paddingRight = this.scrollbarWidth + "px";
this.modal.showModal();
this.modal.scrollTo(0, 0);
}
スクロールロックのためbody
にクラスを付与しています。モーダルに付与していた非表示用のクラスを消します。
デフォルトの<dialog>
ではモーダルオープン時のスクロールロックが実装されていません。なので今回はクラスの付け替えで対応しています。スクロールロックの仕方は他にもあるかと思うので、適宜別の方法で実装してもよいかと思います。
スクロールバーがあれば、スクロールバーの幅分パディングを設定します。
モーダル内がスクロールできる場合、this.modal.scrollTo(0, 0);
で一番上に戻します。この設定がないと前回開いたモーダルのスクロール位置のままになってしまいます。
モーダルを閉じる
// モーダルを閉じる
closeModal() {
this.body.classList.remove(this.MODAL_CLASS);
this.modal.close();
}
// キャンセル(esc押下)されたとき
onModalCancel() {
this.body.classList.remove(this.MODAL_CLASS);
}
// モーダルの背景を押されたとき
onModalClick(e) {
if (!e.target.closest(this.modalInner)) {
this.body.classList.remove(this.MODAL_CLASS);
this.modal.close();
}
}
// modalのcloseイベント
async onModalClose() {
this.body.style.paddingRight = "";
await this.waitDialogAnimation(this.modal);
this.modal.classList.add(this.HIDDEN_CLASS);
}
スクロールロック用にbody
につけたクラスを削除します。 キャンセルされた(esc
キーが押された)場合のイベント設定にはonModalCancel()
を使います。
デフォルトでは背景を押してもモーダルは閉じません。背景クリックでも消せた方がユーザビリティがいいかと思うので設定します。
onModalClick(e)
で<dialog class="dialog js-dialog is-hidden"></dialog>
以外が押されたらモーダルを閉じます。closest()
を使うことで指定した要素(この場合<div class="dialog__inner js-dialog__inner">
)の親を対象にできます。
モーダルのアニメーション JS版
モーダルをふわっと消すようなアニメーションを設定したい場合、cssでアニメーションを設定しても、dialog要素ごと消えてしまうのでうまく設定できません(※Chrome 117 以降からcssだけで設定できるようになりました。後述)。ここではasync/awaitを使ってアニメーションの終了イベントを取得し、アニメーションが終了したら非表示クラスを付与して消す方法をとります。
/**
* 指定されたダイアログ内のすべてのアニメーションが完了するのを待ちます。
*
* @param {HTMLDialogElement} dialog - アニメーションを含むダイアログ要素。
* @returns {Promise<Array<PromiseSettledResult>>} 各アニメーションの完了状態ごとに解決される配列を返す Promise です。
*/
async waitDialogAnimation(dialog) {
return Promise.allSettled(
Array.from(dialog.getAnimations()).map(animation => animation.finished)
);
}
async関数でアニメーションの終了を取得します。
モーダルクローズ時にアニメーションの終了を取得し、アニメーションが終了したら非表示クラスを付与。また、body
につけていたパディングも消しておきます。
// modalのcloseイベント
async onModalClose() {
this.body.style.paddingRight = "";
await this.waitDialogAnimation(this.modal);
this.modal.classList.add(this.HIDDEN_CLASS);
}
モダールの設定は以下のように行います。
// モーダルの設定
document.addEventListener("DOMContentLoaded", () => {
const modal = document.querySelector(".js-dialog");
const modalInner = ".dialog__inner";
const openTrigger = document.querySelector(".js-btn--open");
const closeTrigger = document.querySelector(".js-btn--close");
const modalManager = new ModalManager(modal, modalInner, closeTrigger, openTrigger);
modalManager.init();
});
DOMContentLoaded
はDOMが読み込まれた後に実行したいだけなので、状況に応じて対応してもらって大丈夫です。
- モーダルのタグ
- モーダルの中身のタグ
- 開くボタン
- 閉じるボタン
の4つを指定して、インスタンスを生成します。生成したインスタンスでinit()
を実行してもらえればOKです。
CSSのみでモーダルをアニメーションさせる
デモはこちら。
See the Pen <dialog> demo with CSSAnimation by mtoutside (@mtoutside) on CodePen.
cssはこちら
.btn {
cursor: pointer;
}
body.is-modal {
overflow: hidden;
}
dialog,
::backdrop {
opacity: 0;
transition: opacity 1s, display 1s allow-discrete, overlay 1s allow-discrete;
}
::backdrop {
background: rgba(94, 94, 94, 0.5);
backdrop-filter: blur(4px);
}
dialog[open],
dialog[open]::backdrop {
opacity: 1;
}
@starting-style {
dialog[open],
dialog[open]::backdrop {
opacity: 0;
}
}
.dialog {
display: block;
position: fixed;
top: 0;
bottom: 0;
left: 0;
transform-origin: center;
padding: 0;
min-width: 600px;
margin: auto;
}
.dialog__inner {
padding: 16px;
}
.dialog__text {
margin: 500px 0;
}
@starting-style
はトランジションでの変化前のスタイルを適用させることができます。dialog
のオープン時とdialog
の背景の変化前にopacity: 0;
を設定しておきます。
popover
やdialog
など最上位レイヤーをフェードアウトさせるにはトランジションのリストにoverlay
プロパティを追加します。
transition-behavior
プロパティにallow-discrete
を設定します。
アニメーションを開始し、50%で初期値から最終値へと反転します。また、display: none;
やcontent-visibility: hidden;
が初期値や最終値に設定されている場合、トランジション中はvisible
になります。
なので、dialog
や::backdrop
には
dialog,
::backdrop {
opacity: 0;
transition: opacity 1s, display 1s allow-discrete, overlay 1s allow-discrete;
}
dialog[open],
dialog[open]::backdrop {
opacity: 1;
}
@starting-style {
dialog[open],
dialog[open]::backdrop {
opacity: 0;
}
}
を設定します。
まとめ
ざっくりとですがdialog
を使用したモーダル実装の解説でした。CSSだけでトランジションアニメーションを設定できるのはかなり楽だなと思いました。ですが、2023年9月時点だとまだchromeぐらいでしか使えないのが難点です。safariやFirefoxでの実装が待ち遠しいですね。
参考