本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ボタン

登録ボタンを押すと紙吹雪が弾け、ひらひらと舞い落ちる

デモ

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

仕様

用途
ボタン
動作
DOM
種類
レシピ
使うもの
Physics2DPlugin
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

登録のボタンを押すと、ボタンの横幅の中ほどから 96 枚の紙片が扇状に弾け、回ったり裏返ったりしながら舞い落ちます。紙片は要素にせず、座標だけを持つ素のオブジェクトにして、1 枚ずつ Physics2DPlugin で初速・角度・重力・空気抵抗を与えて飛ばし、画面いっぱいの canvas 1 枚に描きます。ボタンは 2 段の文言の列を上げて「先行登録する」から「登録しました」に変わり、地が成功の緑になって、入力欄にもチェックが付きます。

初速は 1600〜2800 px/秒、向きは真上から左右 62° の扇で、フォームの中央へ寄せるため少し左へ傾けます。いちばん効いているのは friction の 0.12〜0.16 です。1/30 秒ごとに速さへ(1 − friction)を掛ける空気抵抗で、勢いよく上がってすぐ止まり、ゆっくり落ちる紙吹雪らしい動きになります。0 にすると、ただの放物線を描いて、すぐ画面の下へ落ちてしまいます。

ひらひらは、回転(140〜560°/秒)、縦の縮みを cos で往復させる裏返り、落ち始めてから大きくなる横揺れを重ねて作り、どれも弾けてからの時間から毎回計算します。裏の間は表の色を白へ 45% 寄せると、紙の両面に見えます。小さな要素を 100 個回すと重く、縁の描き方もぶれますが、canvas なら毎回全面を描き直すだけなので、続けて弾けても崩れません。

送信は form の submit で受けるので、Enter キーでもボタンでも同じように弾けます。紙吹雪は目で見る人向けの飾りなので、canvas は読み上げから外し、登録できたことは role="status" の知らせに書いて届けます。一度登録したら、二重に送らないようボタンを押せない状態にします。動きを減らす設定では、紙吹雪を出さず、ボタンの切り替えだけにします。

紙片の数、扇の広さと傾き、空気抵抗で印象が変わります。色は CSS の変数に並べてあるので、サイトの色に差し替えられます。数を減らして初速を落とすと控えめな祝福に、増やすと華やかになります。先行登録・購入完了・目標達成・キャンペーン応募の「おめでとう」に向いています。

コード

HTML
<!-- 先行登録のフォーム。送ると紙吹雪が弾け、ボタンが「登録しました」に変わる -->
<form class="signup" action="/signup/" method="post">
  <div class="signup__field">
    <svg class="signup__mail" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      <rect x="3.5" y="5.5" width="17" height="13" rx="2.5" />
      <path d="M4 7l8 6 8-6" />
    </svg>
    <!-- type="email" はドットの無いドメインも通すので、pattern で「@ の後にドット」を求める -->
    <input class="signup__input" type="email" name="email" required pattern=".+@.+\..+" autocomplete="email" placeholder="sample@higashiyuki.com" aria-label="メールアドレス">
    <span class="signup__check" aria-hidden="true">
      <svg viewBox="0 0 24 24" focusable="false"><path d="M6 12.5l4 4L18 8" /></svg>
    </span>
  </div>
  <button class="signup__button" type="submit">
    <!-- 文言は 2 段の列。2 段目は見た目だけで、名前は「先行登録する」のまま -->
    <span class="signup__labels">
      <span class="signup__label">先行登録する<span aria-hidden="true">→</span></span>
      <span class="signup__label" aria-hidden="true">
        <svg viewBox="0 0 24 24" focusable="false"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
        登録しました
      </span>
    </span>
  </button>
  <!-- 登録できたことは、ここに書いて読み上げに届ける(紙吹雪は目で見る人向けの飾り) -->
  <p class="signup__status" role="status" data-done="先行登録を受け付けました。公開のお知らせをメールでお送りします。"></p>
</form>

CSS
.signup {
  --ink: #eeece6;          /* ボタンの地と入力の文字 */
  --on-ink: #111111;       /* ボタンの文字 */
  --pressed: #d6d4ce;      /* 押した瞬間のボタン(明るい地を少し沈める) */
  --success: #4f8a64;      /* 登録できたときのボタンとチェック */
  --on-success: #ffffff;
  --surface: #262626;      /* フォームの地 */
  --line: rgba(238, 236, 230, 0.14);
  --mute: #898989;         /* メールのアイコンと入力例 */
  --confetti: #ee594f, #ef9a39, #efd139, #25d07a, #47a7eb, #e85ea3; /* 紙片の色。裏は白へ 45% 寄せた色 */
  display: flex;
  align-items: center;
  max-width: 560px;
  height: 64px;
  box-sizing: border-box;
  padding: 8px;
  border-radius: 999px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
}

/* 入力欄の枠は、キーボードで入ったときに文字の色にする */
.signup:has(.signup__input:focus-visible) { box-shadow: inset 0 0 0 1px var(--ink); }

.signup__field { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 0 12px 0 16px; }
.signup__mail { flex: none; width: 22px; height: 22px; fill: none; stroke: var(--mute); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }

.signup__input {
  flex: 1;
  min-width: 0;
  height: 48px;
  padding: 0;
  border: 0;
  outline: none;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 15px;
}

.signup__input::placeholder { color: var(--mute); }

/* 入力欄の右のチェック。visibility だけでなく opacity も 0 にしておく(出すときに一瞬光らない) */
.signup__check { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--success); visibility: hidden; opacity: 0; }
.signup__check svg { width: 16px; height: 16px; fill: none; stroke: var(--on-success); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }

.signup__button {
  flex: none;
  width: 208px;
  height: 48px;
  padding: 0;
  overflow: hidden; /* 2 段の文言を、ボタンの窓で切り抜く */
  border: 0;
  border-radius: 999px;
  background-color: var(--ink);
  font: inherit;
  font-size: 15px; /* font: inherit の後に書く(先だと打ち消される) */
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.signup__button[aria-disabled="true"] { cursor: default; }
.signup__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.signup__labels { display: block; }
.signup__label { display: flex; align-items: center; justify-content: center; gap: 8px; height: 48px; white-space: nowrap; color: var(--on-ink); }
.signup__label + .signup__label { color: var(--on-success); }
.signup__label svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; }

/* 読み上げにだけ届ける文 */
.signup__status { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* 紙吹雪を描く canvas。画面いっぱいに重ね、下のクリックを止めない */
.signup-confetti { position: fixed; inset: 0; z-index: 1000; width: 100%; height: 100%; pointer-events: none; }

/* 狭い画面では、入力欄とボタンを 2 段に積む */
@media (max-width: 479px) {
  .signup { flex-direction: column; align-items: stretch; gap: 8px; height: auto; border-radius: 32px; }
  .signup__button { width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .signup-confetti { display: none; }
}

JavaScript
import { gsap } from "gsap";
import { Physics2DPlugin } from "gsap/Physics2DPlugin";

gsap.registerPlugin(Physics2DPlugin);

const COUNT = 96; // 紙片の数
const [LIFE, FADE] = [3.4, 0.35]; // 舞っている時間と、最後に薄れて消える時間(秒)
const SHAPES = [[10, 18], [12, 12], [11, 11, true], [6, 23]]; // 短冊・四角・丸・細いリボン(幅・高さ px)
const TAU = Math.PI * 2;
const { random, interpolate } = gsap.utils;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 紙吹雪は、画面いっぱいの canvas 1 枚に描く(紙片を 100 個の要素にして動かすより軽い)
const canvas = document.body.appendChild(Object.assign(document.createElement("canvas"), { className: "signup-confetti" }));
canvas.setAttribute("aria-hidden", "true");
const g = canvas.getContext("2d");
const bursts = new Set(); // 舞っている紙吹雪(続けて弾けても重ねて描く)
const fit = () => Object.assign(canvas, { width: innerWidth * devicePixelRatio, height: innerHeight * devicePixelRatio });
fit();
addEventListener("resize", fit);

// 紙片を描く。age は弾けてからの秒数。位置(x, y)は Physics2DPlugin が動かしている
function draw(pieces, age) {
  if (age <= 0) return;
  g.globalAlpha = Math.min(1, (LIFE - age) / FADE);
  const drift = Math.min(1, age / 0.8); // 横揺れは、落ち始めてから大きくする
  for (const p of pieces) {
    const flip = Math.cos(p.flipAt + age * p.flip * TAU); // 裏返り(縦の縮み)。負の間は裏
    g.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
    g.translate(p.x + p.sway * drift * Math.sin(p.swayAt + age * p.swayRate * TAU), p.y);
    g.rotate(((p.turn + p.spin * age) * Math.PI) / 180);
    g.scale(1, flip);
    g.fillStyle = flip >= 0 ? p.front : p.back;
    g.beginPath();
    if (p.round) g.ellipse(0, 0, p.w / 2, p.h / 2, 0, 0, TAU);
    else g.roundRect(-p.w / 2, -p.h / 2, p.w, p.h, Math.min(2, p.w / 3));
    g.fill();
  }
}

function paint() {
  g.setTransform(1, 0, 0, 1, 0, 0);
  g.clearRect(0, 0, canvas.width, canvas.height);
  bursts.forEach(({ pieces, anim }) => draw(pieces, anim.time()));
  g.globalAlpha = 1;
  if (bursts.size === 0) gsap.ticker.remove(paint); // 舞い終えたら、描くのをやめる
}

// ボタンの横幅の中ほどから、扇状(真上から左右 62°、少し左へ傾ける)に弾けさせる
function burst(button, colors) {
  const r = button.getBoundingClientRect();
  const item = { pieces: [], anim: gsap.timeline({ delay: 0.05, onComplete: () => { bursts.delete(item); paint(); } }) };
  for (let i = 0; i < COUNT; i++) {
    const [w, h, round] = SHAPES[i % SHAPES.length];
    const front = random(colors);
    const back = interpolate(front, "#ffffff", 0.45); // 裏は明るく
    const p = { x: r.left + r.width * random(0.2, 0.8), y: r.top + r.height * random(0.35, 0.65), w, h, round, front, back };
    Object.assign(p, {
      turn: random(0, 360), spin: random([-1, 1]) * random(140, 560), // 向き(度)と回る速さ(度/秒)
      flip: random(1.2, 3.4), flipAt: random(0, TAU), // 裏返る回数(回/秒)
      sway: random(8, 30), swayRate: random(0.6, 1.3), swayAt: random(0, TAU), // 横揺れの幅(px)と回数(回/秒)
    });
    // friction(空気抵抗)が効いて、勢いよく上がってすぐ止まり、ゆっくり落ちる
    const physics2D = { velocity: random(1600, 2800), angle: random(-166, -42), gravity: random(1250, 1650), friction: random(0.12, 0.16) };
    item.anim.to(p, { physics2D, duration: LIFE, ease: "none" }, 0);
    item.pieces.push(p);
  }
  bursts.add(item);
  gsap.ticker.add(paint);
}

document.querySelectorAll(".signup").forEach((form) => {
  const [input, check, button, labels, status] = ["input", "check", "button", "labels", "status"].map((part) => form.querySelector(`.signup__${part}`));
  // 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
  const styles = getComputedStyle(form);
  const css = (name) => styles.getPropertyValue(name).trim();
  let done = false;
  // ボタンは一瞬沈めてから成功の色にし、文言の列を 1 段上げる。入力欄にもチェックを出す
  const success = gsap.timeline({ paused: true })
    .fromTo(button, { backgroundColor: css("--ink") }, { backgroundColor: css("--pressed"), duration: 0.08, ease: "none" }, 0)
    .to(button, { backgroundColor: css("--success"), duration: 0.3, ease: "none" }, 0.1)
    .to(labels, { yPercent: -50, duration: 0.42, ease: "power3.inOut" }, 0.08)
    .fromTo(check, { autoAlpha: 0, x: -9 }, { autoAlpha: 1, x: 0, duration: 0.3, ease: "power3.out" }, 0.25)
    .timeScale(reduceMotion ? 20 : 1); // 動きを減らす設定では、ほぼ一瞬で切り替える

  // Enter キーでもボタンでも、form の submit で受ける。実際の送信(fetch など)は、ここで行ってから弾けさせる
  form.addEventListener("submit", (event) => {
    event.preventDefault();
    if (done) return; // 二重に送らない
    done = true;
    input.readOnly = true;
    button.setAttribute("aria-disabled", "true");
    status.textContent = status.dataset.done;
    success.play();
    if (!reduceMotion) burst(button, css("--confetti").split(",").map((color) => color.trim()));
    form.dispatchEvent(new CustomEvent("signup", { bubbles: true, detail: { email: input.value } }));
  });
});

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

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