デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- 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 の変数に並べてあるので、サイトの色に差し替えられます。数を減らして初速を落とすと控えめな祝福に、増やすと華やかになります。先行登録・購入完了・目標達成・キャンペーン応募の「おめでとう」に向いています。
コード
<!-- 先行登録のフォーム。送ると紙吹雪が弾け、ボタンが「登録しました」に変わる -->
<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>
.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; }
}
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 } }));
});
});