HTMLのdialogタグを使用してモーダルを作成してみる
みなさんはモーダルを作成する際は、どのように作成されているでしょうか。 モーダルを1から作ろうとすると、フォーカスや背景の読み上げなど見るところが結構あり、なかなか骨が折れるかと思います。 dialogタグを使用すれば1から作るより、はるかに楽に作成することができます。 では実際にdialogタグを使用しモーダルを作成してみましょう。
dialogタグについて
dialogを表示するには.showModal()を使用し、閉じるときは.close()を使用します。 .showModal()を使用するとdialogタグにopen属性が付与され、モーダルが出現します。 またデフォルトでEscキーでモーダルを閉じ、モーダルがopenの状態だと一番最初のフォーカス可能な要素にフォーカスが当たります。 下記の実装でボタン押下でモーダルが開く/閉じるが実装できます。
EJS
<body>
<button data-modal-open>モーダルを開く</button>
<dialog class="modal">
<div class="modal__inner">
<button data-modal-close>モーダルを閉じる</button>
</div>
</dialog>
</body>
TypeScript
const openTrigger = document.querySelector<HTMLButtonElement>('button[data-modal-open]');
const closeTrigger = document.querySelector<HTMLButtonElement>('button[data-modal-close]');
const modalElement = document.querySelector<HTMLDialogElement>('.modal');
openTrigger?.addEventListener('click', () => {
modalElement?.showModal();
});
closeTrigger?.addEventListener('click', () => {
modalElement?.close();
});
このままの実装でも使用はできますが、アニメーションや背景スクロールの固定ができていないためその辺りも実装してみましょう。
スタイリングする
最低限の実装ですと見た目がモーダルっぽくないので軽くスタイリングしておこうと思います。 背景のオーバーレイは::backdrop疑似要素でスタイリングし、デフォルトの背景色が薄いため少し濃くします。 使用しているブラウザやリセットCSSの度合いにもよりますが、下記のCSSで大体モーダルっぽくなるかと思います。
Sass
.modal {
width: min(90%, 800px);
height: min(60%, 600px);
margin: auto;
border: none;
&::backdrop {
background-color: rgba(0, 0, 0, .6);
backdrop-filter: blur(1px);
}
}
.modal__inner {
width: 100%;
height: 100%;
padding: 2em;
overflow: auto;
overscroll-behavior: contain;
}
背景のガタつきをなくす
ページにスクロールバーが出ているときにモーダルのON/OFFで、スクロールバーが消え背景がガタついています。 解決方法としてスクロールバーの幅を計算し、モーダルが出現しているときだけbodyにpaddingを付与してあげます。
TypeScript
…
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
openTrigger?.addEventListener('click', () => {
modalElement?.showModal();
document.body.style.setProperty('padding-right', `${scrollbarWidth}px`);
});
closeTrigger?.addEventListener('click', () => {
modalElement?.close();
document.body.style.removeProperty('padding-right');
});
scrollbar-gutter: stableで固定する方法もあるようですが当記事では行ないません。 気になる方は調べてみてください。
背景を固定する
モーダルが出現している間、背景がスクロールしないように固定してあげます。
Sass
body:has(dialog[open]) {
overflow: hidden;
}
2023年12月19日リリースのFirefox 121でも:has()がサポートされ、全モダンブラウザで使用可能となりました。 Javascriptでoverflow: hidden;をつけなくてよくなり、背景スクロールの固定がより楽になりました。 しかしiOSでは完全に固定できないため、iOSでもスクロールを完全に固定するにはJavaScriptの実装が必要です。 本記事では:has()を使用し、iOSでのスクロール固定は対応しないこととします。
背景押下でモーダルを閉じる
大体のモーダルの実装ですと、背景を押下するとモーダルが閉じるようになっているかと思います。 dialogにデフォルトでそのような機能はないため、背景押下でモーダルが閉じるようにします。 背景押下でモーダルを閉じるようにするには必ずdialogタグの中にinnerとなるような要素が必要です。 今回は最初に用意したHTMLにあらかじめinnerとなるdiv要素を入れているのでJavaScriptの実装のみで良いです。
TypeScript
…
modalElement?.addEventListener('click', (e) => {
const targetElement = e.target as HTMLElement;
if (!targetElement.closest('.modal__inner')) {
modalElement?.close();
document.body.style.removeProperty('padding-right');
}
});
closest()を使用することでmodal__innerの親要素を対象にすることで背景部分の押下でモーダルが閉じます。 また今回の場合は使用しても問題ないと判断したため型アサーションしています。
モーダル内のスクロール位置について
基本的には、モーダル内を途中までスクロールしてモーダルを閉じ、再度開くとスクロール位置がそのままになっています。 しかし.showModal()で開くとフオーカス可能な最初の要素にフォーカスが当たるため、モーダル内の一番上にクローズボタンなどがあればスクロール位置は一番上に戻ります。 フォーカスする要素がない場合(大体あると思いますが...)や途中にある場合はスクロール位置をトップに戻す必要があります。
TypeScript
…
openTrigger?.addEventListener('click', () => {
modalElement?.showModal();
document.body.style.setProperty('padding-right', `${scrollbarWidth}px`);
document.querySelector('.modal__inner')?.scrollTo(0, 0);
});
仮に最初にフォーカス可能な要素がモーダル内の最下部にあった場合、上記の方法でスクロール位置は一番上に戻りますがフォーカスはその要素に当たったままです。
モーダルをアニメーションさせる
モーダルを開いたときにパッと画面に出ますが、少しふわっと下から出現するアニメーションをつけようと思います。 フェードイン用とフェードアウト用のkeyframesを用意しフェードアウト用のanimationを指定します。 モーダルにopen属性がついたときにフェードイン用に上書きするよう記述します。
Sass
.modal {
…
animation: fadeOut 0.3s ease;
@keyframes fadeIn {
from {
opacity: 0;
translate: 0 10px;
}
to {
opacity: 1;
translate: 0 0;
}
}
@keyframes fadeOut {
from {
opacity: 1;
translate: 0 0;
}
to {
opacity: 0;
translate: 0 10px;
}
}
&[open] {
animation: fadeIn 0.3s ease;
}
…
}
この状態でモーダルをオープンにするとアニメーションします。 ですが、クローズするときはアニメーションしません。 dialogタグは単純にdisplay: none/blockを切り替えて出しているからです。 そのためアニメーションが終了するのを待ってからdisplayを切り替える必要があります。 まずはクローズしている状態のclassを付与するか直接スタイルを当てるかします。 今回は直接dialogタグに対してスタイルを当てます。
EJS
<body>
<button data-modal-open>モーダルを開く</button>
<dialog class="modal" style="display: none;">
<div class="modal__inner">
<button data-modal-close>モーダルを閉じる</button>
</div>
</dialog>
</body>
直接当てたスタイルを取り除く際のことを考えてdialogに振っているclassにもスタイルを当てます。
Sass
.modal {
…
display: block;
animation: fadeOut 0.3s ease;
…
}
後はモーダルをオープンするときに直接当てたスタイルを削除し、閉じるときはアニメーションが終了してからスタイルを付与するようにすればアニメーションします。
TypeScript
…
openTrigger?.addEventListener('click', () => {
modalElement?.removeAttribute('style');
…
});
…
modalElement?.addEventListener('close', (e) => {
document.body.style.removeProperty('padding-right');
waitDialogAnimation(e.target as HTMLDialogElement)
.then(() => {
modalElement.style.setProperty('display', 'none');
})
.catch((error) => {
console.error('Error', error);
});
});
const waitDialogAnimation = (dialog: HTMLDialogElement) =>
Promise.allSettled(
Array.from(dialog.getAnimations({ subtree: true })).map((animation) => animation.finished),
);
最終的なコード
以下が最終的なコードになります。
EJS
<body>
<button data-modal-open>モーダルを開く</button>
<dialog class="modal" style="display: none;">
<div class="modal__inner">
<button data-modal-close>モーダルを閉じる</button>
</div>
</dialog>
</body>
Sass
body:has(dialog[open]) {
overflow: hidden;
}
.modal {
width: min(90%, 800px);
height: min(60%, 600px);
margin: auto;
border: none;
display: block;
animation: fadeOut 0.3s ease;
@keyframes fadeIn {
from {
opacity: 0;
translate: 0 10px;
}
to {
opacity: 1;
translate: 0 0;
}
}
@keyframes fadeOut {
from {
opacity: 1;
translate: 0 0;
}
to {
opacity: 0;
translate: 0 10px;
}
}
&[open] {
animation: fadeIn 0.3s ease;
}
&::backdrop {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(1px);
}
}
.modal__inner {
width: 100%;
height: 100%;
padding: 2em;
overflow: auto;
overscroll-behavior: contain;
}
TypeScript
const openTrigger = document.querySelector<HTMLButtonElement>('button[data-modal-open]');
const closeTrigger = document.querySelector<HTMLButtonElement>('button[data-modal-close]');
const modalElement = document.querySelector<HTMLDialogElement>('.modal');
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
openTrigger?.addEventListener('click', () => {
modalElement?.removeAttribute('style');
modalElement?.showModal();
document.body.style.setProperty('padding-right', `${scrollbarWidth}px`);
document.querySelector('.modal__inner')?.scrollTo(0, 0);
});
closeTrigger?.addEventListener('click', () => {
modalElement?.close();
document.body.style.removeProperty('padding-right');
});
modalElement?.addEventListener('click', (e) => {
const targetElement = e.target as HTMLElement;
if (!targetElement.closest('.modal__inner')) {
modalElement?.close();
document.body.style.removeProperty('padding-right');
}
});
modalElement?.addEventListener('close', (e) => {
document.body.style.removeProperty('padding-right');
waitDialogAnimation(e.target as HTMLDialogElement)
.then(() => {
modalElement.style.setProperty('display', 'none');
})
.catch((error) => {
console.error('Error', error);
});
});
const waitDialogAnimation = (dialog: HTMLDialogElement) =>
Promise.allSettled(
Array.from(dialog.getAnimations({ subtree: true })).map((animation) => animation.finished),
);
追記:複数のモーダルの実装
これまでのモーダルの実装ですと、モーダルが複数あった場合にうまく動作しません(考慮漏れてました)。 ページのモーダルはひとつと限りませんので、複数のモーダルを設置できるように変更したコードを記載します。 まずはHTMLのモーダル、ボタンを2つにします。モーダルとボタンを紐づけるためにidを付与します。
EJS
<body>
<button data-modal-open="modal1">モーダル1を開く</button>
<button data-modal-open="modal2">モーダル2を開く</button>
<dialog class="modal" id="modal1" style="display: none;">
<div class="modal__inner">
<button data-modal-close>モーダルを閉じる</button>
<p>モーダル1の内容</p>
</div>
</dialog>
<dialog class="modal" id="modal2" style="display: none;">
<div class="modal__inner">
<button data-modal-close>モーダルを閉じる</button>
<p>モーダル2の内容</p>
</div>
</dialog>
</body>
JavaScriptの方は各イベント発火時に紐づけされたものを対象にするように変更しています。 これで複数のモーダルが設置できます。
TypeScript
const openTriggers = document.querySelectorAll<HTMLButtonElement>('button[data-modal-open]');
const closeTriggers = document.querySelectorAll<HTMLButtonElement>('button[data-modal-close]');
const modalElements = document.querySelectorAll<HTMLDialogElement>('.modal');
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
openTriggers.forEach((trigger) => {
trigger.addEventListener('click', () => {
const modalId = trigger.getAttribute('data-modal-open');
const modalElement = Array.from(modalElements).find((el) => el.id === modalId);
if (modalElement) {
modalElement?.removeAttribute('style');
modalElement?.showModal();
document.body.style.setProperty('padding-right', `${scrollbarWidth}px`);
document.querySelector('.modal__inner')?.scrollTo(0, 0);
}
});
});
closeTriggers.forEach((trigger) => {
trigger.addEventListener('click', () => {
const modalId = (trigger.closest('.modal') as HTMLDialogElement).id;
const modalElement = Array.from(modalElements).find((el) => el.id === modalId);
if (modalElement) {
modalElement?.close();
document.body.style.removeProperty('padding-right');
}
});
});
modalElements.forEach((modal) => {
modal.addEventListener('click', (e) => {
const targetElement = e.target as HTMLElement;
if (!targetElement.closest('.modal__inner')) {
modal?.close();
document.body.style.removeProperty('padding-right');
}
});
modal.addEventListener('close', (e) => {
document.body.style.removeProperty('padding-right');
waitDialogAnimation(e.target as HTMLDialogElement)
.then(() => {
modal.style.setProperty('display', 'none');
})
.catch((error) => {
console.error('Error', error);
});
});
});
const waitDialogAnimation = (dialog: HTMLDialogElement) =>
Promise.allSettled(
Array.from(dialog.getAnimations({ subtree: true })).map((animation) => animation.finished)
);
1からdivタグ等を使い作るよりもdialogタグを使用した方が、はるかに楽にモーダルを作れました。 MDNの記事を参考に作りましたので細かな仕様はそちらを参考にしてみてください。 すぐに動作確認できるようにサンプルコードも置いておきます。
この記事のサンプルコードはこちら