本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

背景が暗くなり、モーダルが弾みながら拡大して開く

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

後ろのページに幕をかけながら、ダイアログを少し下から拡大して弾ませます。中身は、アイコン・見出し・本文・一覧・ボタンの順に、少しずつ遅れて下から浮かびます。閉じるときは同じタイムラインを逆にたどるので、中身が消えてからダイアログが縮みます。

幕は 0.35 秒かけて暗くなりますが、ダイアログの不透明度は 0.2 秒で先に上げます。幕だけが暗くなって、透けたダイアログが見える一瞬を作らないためです。大きさは 0.9 倍から、位置は 24px 下から、0.5 秒の back.out(1.5) で少し行き過ぎてから収まります。中身は 0.12 秒後から、0.05 秒ずつずらして出します。

開いたら焦点をダイアログの中へ移し、閉じたら元のボタンへ戻します。showModal で開けば、焦点の閉じ込め・背面の締め出し・Esc・戻し先の記憶まで任せられます。ただし閉じる動きは自分で見せるので、close を呼ぶのは縮み終わってからにします。透明にするのも不透明度だけにします。要素ごと見えなくすると、開いた瞬間に中のボタンが焦点を受け取れません。

幕を押したら閉じる形にするときは、内側の余白を中の箱に持たせます。ダイアログ自身が余白を持っていると、文字のまわりの空きを押しただけで閉じてしまいます。押した先がダイアログ自身かどうかで、幕を押したかを見分けられます。

弾みを抑えたいときは、行き過ぎの強さを下げ、始まりの大きさを 0.96 倍ほどまで寄せます。閉じる速さは開くときの 1.6 倍にしてあり、開くより短いほうが待たされた感じになりません。確認のダイアログ・アップグレードの案内・ログインの入力に向いています。

コード

HTML
<button class="modal-open" type="button" aria-haspopup="dialog" aria-controls="upgrade-dialog">
  プランをアップグレード
</button>

<!-- showModal() で開く <dialog> には、焦点の閉じ込め・背面の締め出し・Esc・焦点の戻しが付いてくる。
     余白を中の箱に持たせると、幕を押したかどうかを「押した先が 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">Upgrade to Pro</h2>
    <p class="modal__part modal__text">忙しいチームのための機能で、仕事の幅をもっと広げられます。</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>
CSS
.modal {
  --ink: #262626; /* 文字と主ボタンの地 */
  --surface: #ffffff; /* 面の色 */
  --mute: #6b6b6b; /* 補足の文字 */
  --line: rgba(38, 38, 38, 0.12); /* 縁と区切り線 */
  width: min(512px, calc(100vw - 48px));
  padding: 0; /* 余白は中の箱が持つ(幕を押したかどうかを見分けるため) */
  border: 1px solid var(--line);
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
}

.modal__body { position: relative; padding: 40px; }

/* 幕。開いた瞬間の始まりの値を @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; }
.modal__text { margin: 8px 0 0; color: var(--mute); font-size: 14px; line-height: 1.8; }

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

@media (prefers-reduced-motion: reduce) {
  .modal::backdrop { transition-duration: 0.01s; }
}
JavaScript
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 自身を押したときだけ「幕を押した」になる
  dialog.addEventListener("click", (event) => {
    if (event.target === dialog) close();
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。