デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ダイアログが弾むように開くと、続けて中身がアイコン、見出し、本文、一覧、ボタンの順に、少しずつ遅れて下から浮かび上がります。ダイアログ自体は0.9倍の大きさと少し下の位置から動き始め、back.out(1.5) で目標をわずかに行き過ぎてから収まります。閉じるときは同じタイムラインを逆にたどるので、中身が消えてからダイアログが縮みます。
後ろを暗くするオーバーレイは0.35秒かけて濃くなりますが、ダイアログの不透明度は0.2秒で先に上げきることにしました。こうしておけば、背景だけが暗くなってダイアログがまだ透けて見える、という一瞬がなくなります。
開くのは showModal に任せています。中へのフォーカスの閉じ込め、後ろのページを操作できなくする処理、Esc キーで閉じる操作、閉じたあと元のボタンへフォーカスを戻す処理まで、ブラウザが引き受けてくれます。ただし閉じる動きは自分で見せるので、close は縮み終わってから呼びます。先に呼ぶと、ダイアログがその場で消えてしまいます。開く前に透明にしておくのも、不透明度だけで行っています。visibility などで要素ごと見えなくすると、開いた瞬間に中のボタンがフォーカスを受け取れません。
オーバーレイを押したら閉じる形にするなら、内側の余白はダイアログ自身ではなく、中に入れた要素に持たせます。ダイアログ自身が余白を持っていると、文字のまわりの空いた部分を押しただけで閉じてしまいます。中の要素に持たせておけば、押した先がダイアログ自身かどうかを見るだけで、オーバーレイを押したと判断できます。弾みを抑えたいときは、行き過ぎの強さを下げ、始まりの大きさを0.96倍ほどまで寄せます。
コード
<button class="modal-open" type="button" aria-haspopup="dialog" aria-controls="upgrade-dialog">
プランをアップグレード
</button>
<!-- showModal() で開く <dialog> には、フォーカスの閉じ込め・背面の操作の無効化・Esc・フォーカスの戻しが付いてくる。
余白を中の要素に持たせると、backdrop を押したかどうかを「押した先が dialog 自身か」で見分けられる -->
<dialog class="modal" id="upgrade-dialog" aria-labelledby="modal-title">
<div class="modal__body">
<!-- フォーカスは、開いたとき最初の操作できる要素(この閉じるボタン)に移る -->
<button class="modal__close" type="button" aria-label="閉じる" data-close>×</button>
<span class="modal__part modal__icon" aria-hidden="true"></span>
<h2 class="modal__part modal__title" id="modal-title">プロプランにアップグレード</h2>
<p class="modal__part modal__text">プロジェクトを無制限に作成でき、<br>メンバーも何人でも招待できます。</p>
<ul class="modal__part modal__list">
<li>プロジェクト無制限</li>
<li>詳しい分析レポート</li>
<li>優先サポート</li>
</ul>
<div class="modal__part modal__actions">
<button class="modal__button" type="button" data-close>あとで</button>
<button class="modal__button modal__button--fill" type="submit">今すぐアップグレード</button>
</div>
</div>
</dialog>
.modal {
--ink: #262626; /* 文字とプライマリボタンの背景色 */
--surface: #ffffff; /* ダイアログの背景色 */
--mute: #6b6b6b; /* 補足の文字 */
--line: rgba(38, 38, 38, 0.12); /* 縁と区切り線 */
width: min(512px, calc(100vw - 48px));
padding: 0; /* 余白は中の要素が持つ(backdrop を押したかどうかを見分けるため) */
border: 1px solid var(--line);
border-radius: 4px;
background-color: var(--surface);
color: var(--ink);
}
.modal__body { position: relative; padding: 40px; }
/* backdrop。開いた瞬間の始まりの値を @starting-style で決め、閉じるときは display を遅らせて残す */
.modal::backdrop {
opacity: 0;
background-color: rgba(17, 17, 17, 0.5);
transition: opacity 0.35s ease-out, display 0.35s allow-discrete, overlay 0.35s allow-discrete;
}
.modal[open]::backdrop { opacity: 1; }
@starting-style { .modal[open]::backdrop { opacity: 0; } }
.modal__close {
position: absolute;
right: 12px;
top: 12px;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: none;
color: var(--mute);
font: inherit;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
/* アイコンの置き場所。実際のサイトでは、この中に SVG を入れる */
.modal__icon {
display: block;
width: 48px;
height: 48px;
border-radius: 4px;
background-color: var(--ink);
}
/* 和文は文節で折り返す(狭い画面で「アップグ/レード」と割れない) */
.modal__title { margin: 24px 0 0; font-size: 24px; font-weight: 700; letter-spacing: -0.01em; word-break: auto-phrase; }
.modal__text { margin: 8px 0 0; color: var(--mute); font-size: 14px; line-height: 1.8; word-break: auto-phrase; }
.modal__list { margin: 24px 0 0; padding: 0; list-style: none; font-size: 14px; line-height: 22px; }
.modal__list li { margin-top: 8px; }
.modal__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 32px;
}
.modal__button {
height: 48px;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--ink);
background: none;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.modal__button--fill {
box-shadow: none;
background-color: var(--ink);
color: var(--surface);
}
.modal__close:focus-visible,
.modal__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 狭い画面では、ボタンを縦に積み、プライマリボタンを上に置く(横並びだと文言が 2 行に折れる) */
@media (max-width: 480px) {
.modal__actions { grid-template-columns: 1fr; }
.modal__button--fill { order: -1; }
}
@media (prefers-reduced-motion: reduce) {
.modal::backdrop { transition-duration: 0.01s; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const CLOSE_SPEED = 1.6; // 閉じるほうは少し速く
document.querySelectorAll(".modal-open").forEach((button) => {
const dialog = document.getElementById(button.getAttribute("aria-controls"));
const parts = dialog.querySelectorAll(".modal__part");
// 閉じる動きが終わってから close() を呼ぶ(先に呼ぶと、その場で消える)
const open = gsap
.timeline({ paused: true, onReverseComplete: () => dialog.close() })
// 透明にするのは opacity だけ。autoAlpha(visibility)だと、開いた瞬間に中のボタンがフォーカスを受けられない
.fromTo(dialog, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "power1.out" }, 0.03)
.fromTo(dialog, { scale: 0.9, y: 24 }, { scale: 1, y: 0, duration: 0.5, ease: "back.out(1.5)" }, 0.03)
// 中身は少し遅れて、上から順に下からせり上げる
.fromTo(parts, { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.4, ease: "power2.out", stagger: 0.05 }, 0.12);
const close = () => open.timeScale(SPEED * CLOSE_SPEED).reverse();
button.addEventListener("click", () => {
dialog.showModal(); // フォーカスが中へ移り、背面は操作できなくなる。閉じるとフォーカスはこのボタンへ戻る
open.timeScale(SPEED).play();
});
dialog.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", close));
// Esc は既定だと即座に閉じるので、いったん止めて同じ動きで閉じる
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
close();
});
// 余白は中の要素が持っているので、dialog 自身を押したときだけ「backdrop を押した」になる
dialog.addEventListener("click", (event) => {
if (event.target === dialog) close();
});
});