紙吹雪サクセスボタン
押すとボタンが完了状態へ変化し、canvasで紙吹雪が物理落下する達成感の演出。購入完了やフォーム送信成功のフィードバックに使えます。
ライブデモ
使用例(お題: SaaS FlowDesk)
この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- FlowDesk: 料金プランカード。申込ボタンを紙吹雪サクセスで演出 -->
<canvas class="confetti" aria-hidden="true"></canvas>
<div class="fd">
<p class="fd__eyebrow">PRICING</p>
<h1 class="fd__h1">プランを選んで、すぐ開始</h1>
<div class="plans">
<article class="plan">
<h2 class="plan__name">Starter</h2>
<p class="plan__price"><b>¥0</b><span>/月</span></p>
<ul class="plan__list">
<li>3プロジェクト</li>
<li>メンバー5名</li>
<li>基本サポート</li>
</ul>
<button class="plan__ghost" type="button">無料で使う</button>
</article>
<article class="plan plan--pop">
<span class="plan__tag">人気</span>
<h2 class="plan__name">Pro</h2>
<p class="plan__price"><b>¥1,480</b><span>/月</span></p>
<ul class="plan__list">
<li>無制限プロジェクト</li>
<li>メンバー50名</li>
<li>自動化・優先サポート</li>
</ul>
<button class="cta" type="button">
<span class="cta__label">Proを申し込む</span>
</button>
</article>
</div>
</div>
CSS
/* FlowDesk SaaS テーマ: 紺/青/白 */
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background:
radial-gradient(circle at 50% -10%, rgba(79,124,255,.24) 0%, transparent 50%),
#0f1b34;
color: #fff;
overflow: hidden;
}
/* 紙吹雪レイヤーは最前面・操作透過 */
.confetti {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
}
.fd {
height: 400px;
display: grid;
align-content: center;
justify-items: center;
gap: 6px;
padding: 0 24px;
}
.fd__eyebrow {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: .2em;
color: #7da0ff;
}
.fd__h1 {
margin: 0 0 10px;
font-size: 24px;
font-weight: 800;
}
.plans {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
width: 100%;
max-width: 440px;
}
.plan {
position: relative;
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.1);
border-radius: 16px;
padding: 18px;
display: grid;
gap: 10px;
}
.plan--pop {
background: rgba(79,124,255,.12);
border-color: rgba(79,124,255,.55);
box-shadow: 0 16px 40px rgba(79,124,255,.25);
}
.plan__tag {
position: absolute;
top: -10px;
right: 14px;
font-size: 11px;
font-weight: 700;
color: #0f1b34;
background: #8fb0ff;
padding: 3px 10px;
border-radius: 999px;
}
.plan__name {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #bcd0ff;
}
.plan__price {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
}
.plan__price b { font-size: 26px; }
.plan__price span { font-size: 12px; color: rgba(255,255,255,.55); }
.plan__list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
font-size: 12px;
color: rgba(255,255,255,.78);
}
.plan__list li::before { content: "✓ "; color: #6f96ff; }
.plan__ghost {
margin-top: 4px;
font-size: 13px;
font-weight: 700;
color: #cfe0ff;
background: transparent;
border: 1px solid rgba(255,255,255,.25);
padding: 10px;
border-radius: 10px;
cursor: pointer;
}
/* 主役: 紙吹雪サクセスボタン */
.cta {
margin-top: 4px;
position: relative;
font-size: 14px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #4f7cff, #6f96ff);
border: none;
padding: 12px;
border-radius: 10px;
cursor: pointer;
overflow: hidden;
box-shadow: 0 10px 26px rgba(79,124,255,.45);
transition: background .3s ease, transform .15s ease;
}
.cta:hover { filter: brightness(1.07); }
.cta:active { transform: scale(.98); }
.cta.is-done {
background: linear-gradient(135deg, #2bd980, #16c46a);
box-shadow: 0 10px 26px rgba(22,196,106,.4);
}
@media (prefers-reduced-motion: reduce) {
.cta { transition: background .3s ease; }
}
JavaScript
// FlowDesk申込: クリックで完了状態にし、canvasで紙吹雪を物理落下させる
(() => {
const btn = document.querySelector('.cta');
const canvas = document.querySelector('.confetti');
if (!btn || !canvas) return; // null安全
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const COLORS = ['#4f7cff', '#8fb0ff', '#2bd980', '#ffd166', '#ff7eb0', '#fff'];
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let particles = [];
let rafId = null;
// 解像度に合わせてcanvasサイズを調整
const resize = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
};
resize();
window.addEventListener('resize', resize);
// ボタン上部から扇状に紙吹雪を放出
const spawn = () => {
const r = btn.getBoundingClientRect();
const cr = canvas.getBoundingClientRect();
const ox = (r.left + r.width / 2 - cr.left) * dpr;
const oy = (r.top - cr.top) * dpr;
for (let i = 0; i < 90; i++) {
const angle = Math.PI * (1.1 + Math.random() * 0.8);
const speed = (4 + Math.random() * 7) * dpr;
particles.push({
x: ox + (Math.random() - 0.5) * r.width * dpr,
y: oy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 4 * dpr,
size: (4 + Math.random() * 5) * dpr,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
color: COLORS[(Math.random() * COLORS.length) | 0],
life: 1
});
}
if (!rafId) loop();
};
// 物理更新&描画
const loop = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const gravity = 0.18 * dpr;
particles.forEach((p) => {
p.vy += gravity;
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.rot += p.vr;
p.life -= 0.006;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
ctx.restore();
});
// 寿命切れ・画面外を除去
particles = particles.filter(p => p.life > 0 && p.y < canvas.height + 40);
if (particles.length) rafId = requestAnimationFrame(loop);
else { ctx.clearRect(0, 0, canvas.width, canvas.height); rafId = null; }
};
let done = false;
btn.addEventListener('click', () => {
if (done) return;
done = true;
btn.classList.add('is-done');
const label = btn.querySelector('.cta__label');
if (label) label.textContent = '申込完了!';
if (!reduce) spawn(); // 抑制時は紙吹雪なし
// デモを繰り返せるよう少し後に戻す
setTimeout(() => {
btn.classList.remove('is-done');
if (label) label.textContent = 'Proを申し込む';
done = false;
}, 2600);
});
})();
コード
HTML
<!-- 紙吹雪サクセスボタン: 押すとcanvasで紙吹雪が舞い、ボタンが完了状態へ -->
<div class="stage">
<canvas class="confetti" aria-hidden="true"></canvas>
<button class="cta" type="button">
<span class="cta__label">購入する</span>
<svg class="cta__check" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12.5l5 5 11-11" />
</svg>
</button>
</div>
CSS
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, sans-serif;
background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
}
.stage {
position: relative;
display: grid;
place-items: center;
width: 100%;
min-height: 100vh;
}
/* 紙吹雪用キャンバスは全面に重ねるが操作は透過 */
.confetti {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* CTAボタン */
.cta {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 200px;
padding: 18px 36px;
font-size: 17px;
font-weight: 700;
letter-spacing: .03em;
color: #fff;
border: none;
border-radius: 14px;
cursor: pointer;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
box-shadow: 0 14px 30px rgba(37, 117, 252, .35);
transition: transform .15s ease, background .4s ease, box-shadow .3s ease;
overflow: hidden;
}
.cta:active { transform: scale(.97); }
/* 完了状態: 緑に変化しチェックが出る */
.cta.is-done {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
box-shadow: 0 14px 30px rgba(56, 239, 125, .4);
pointer-events: none;
}
.cta__label { transition: transform .3s ease, opacity .3s ease; }
.cta.is-done .cta__label {
transform: translateY(-2px);
}
/* チェックマークは初期は隠してストローク描画 */
.cta__check {
width: 0;
height: 22px;
fill: none;
stroke: #fff;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 30;
stroke-dashoffset: 30;
transition: width .25s ease;
}
.cta.is-done .cta__check {
width: 22px;
animation: check-draw .5s ease forwards .12s;
}
@keyframes check-draw {
to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cta, .cta__label, .cta__check { transition: none; }
.cta.is-done .cta__check { animation: none; stroke-dashoffset: 0; }
}
JavaScript
// 紙吹雪サクセスボタン: クリックでボタンを完了状態にし、canvasで紙吹雪を物理落下
(() => {
const btn = document.querySelector('.cta');
const canvas = document.querySelector('.confetti');
if (!btn || !canvas) return; // null安全
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const COLORS = ['#6a11cb', '#2575fc', '#38ef7d', '#ffd166', '#ff5e9c', '#fff'];
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let particles = [];
let rafId = null;
// 解像度に合わせてcanvasサイズを調整
const resize = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
};
resize();
window.addEventListener('resize', resize);
// ボタン中心付近から紙吹雪を放出
const spawn = () => {
const r = btn.getBoundingClientRect();
const cr = canvas.getBoundingClientRect();
const ox = (r.left + r.width / 2 - cr.left) * dpr;
const oy = (r.top - cr.top) * dpr;
const N = 90;
for (let i = 0; i < N; i++) {
const angle = Math.PI * (1.1 + Math.random() * 0.8); // 上方向中心に扇状
const speed = (4 + Math.random() * 7) * dpr;
particles.push({
x: ox + (Math.random() - 0.5) * r.width * dpr,
y: oy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 4 * dpr,
size: (4 + Math.random() * 5) * dpr,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
color: COLORS[(Math.random() * COLORS.length) | 0],
life: 1
});
}
if (!rafId) loop();
};
// 物理更新&描画ループ
const loop = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const gravity = 0.18 * dpr;
particles.forEach((p) => {
p.vy += gravity;
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.rot += p.vr;
p.life -= 0.006;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
ctx.restore();
});
// 寿命切れ・画面外を除去
particles = particles.filter(p => p.life > 0 && p.y < canvas.height + 40);
if (particles.length) {
rafId = requestAnimationFrame(loop);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
rafId = null;
}
};
let done = false;
btn.addEventListener('click', () => {
if (done) return;
done = true;
btn.classList.add('is-done');
const label = btn.querySelector('.cta__label');
if (label) label.textContent = '完了!';
if (!reduce) spawn(); // モーション抑制時は紙吹雪なし
// デモを繰り返せるよう少し後に初期状態へ戻す
setTimeout(() => {
btn.classList.remove('is-done');
if (label) label.textContent = '購入する';
done = false;
}, 2600);
});
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「紙吹雪サクセスボタン」の効果を追加してください。
# 追加してほしい効果
紙吹雪サクセスボタン(マイクロインタラクション)
押すとボタンが完了状態へ変化し、canvasで紙吹雪が物理落下する達成感の演出。購入完了やフォーム送信成功のフィードバックに使えます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 紙吹雪サクセスボタン: 押すとcanvasで紙吹雪が舞い、ボタンが完了状態へ -->
<div class="stage">
<canvas class="confetti" aria-hidden="true"></canvas>
<button class="cta" type="button">
<span class="cta__label">購入する</span>
<svg class="cta__check" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12.5l5 5 11-11" />
</svg>
</button>
</div>
【CSS】
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", system-ui, sans-serif;
background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
}
.stage {
position: relative;
display: grid;
place-items: center;
width: 100%;
min-height: 100vh;
}
/* 紙吹雪用キャンバスは全面に重ねるが操作は透過 */
.confetti {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* CTAボタン */
.cta {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 200px;
padding: 18px 36px;
font-size: 17px;
font-weight: 700;
letter-spacing: .03em;
color: #fff;
border: none;
border-radius: 14px;
cursor: pointer;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
box-shadow: 0 14px 30px rgba(37, 117, 252, .35);
transition: transform .15s ease, background .4s ease, box-shadow .3s ease;
overflow: hidden;
}
.cta:active { transform: scale(.97); }
/* 完了状態: 緑に変化しチェックが出る */
.cta.is-done {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
box-shadow: 0 14px 30px rgba(56, 239, 125, .4);
pointer-events: none;
}
.cta__label { transition: transform .3s ease, opacity .3s ease; }
.cta.is-done .cta__label {
transform: translateY(-2px);
}
/* チェックマークは初期は隠してストローク描画 */
.cta__check {
width: 0;
height: 22px;
fill: none;
stroke: #fff;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 30;
stroke-dashoffset: 30;
transition: width .25s ease;
}
.cta.is-done .cta__check {
width: 22px;
animation: check-draw .5s ease forwards .12s;
}
@keyframes check-draw {
to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cta, .cta__label, .cta__check { transition: none; }
.cta.is-done .cta__check { animation: none; stroke-dashoffset: 0; }
}
【JavaScript】
// 紙吹雪サクセスボタン: クリックでボタンを完了状態にし、canvasで紙吹雪を物理落下
(() => {
const btn = document.querySelector('.cta');
const canvas = document.querySelector('.confetti');
if (!btn || !canvas) return; // null安全
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const COLORS = ['#6a11cb', '#2575fc', '#38ef7d', '#ffd166', '#ff5e9c', '#fff'];
let dpr = Math.min(window.devicePixelRatio || 1, 2);
let particles = [];
let rafId = null;
// 解像度に合わせてcanvasサイズを調整
const resize = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
};
resize();
window.addEventListener('resize', resize);
// ボタン中心付近から紙吹雪を放出
const spawn = () => {
const r = btn.getBoundingClientRect();
const cr = canvas.getBoundingClientRect();
const ox = (r.left + r.width / 2 - cr.left) * dpr;
const oy = (r.top - cr.top) * dpr;
const N = 90;
for (let i = 0; i < N; i++) {
const angle = Math.PI * (1.1 + Math.random() * 0.8); // 上方向中心に扇状
const speed = (4 + Math.random() * 7) * dpr;
particles.push({
x: ox + (Math.random() - 0.5) * r.width * dpr,
y: oy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 4 * dpr,
size: (4 + Math.random() * 5) * dpr,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
color: COLORS[(Math.random() * COLORS.length) | 0],
life: 1
});
}
if (!rafId) loop();
};
// 物理更新&描画ループ
const loop = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const gravity = 0.18 * dpr;
particles.forEach((p) => {
p.vy += gravity;
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.rot += p.vr;
p.life -= 0.006;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
ctx.restore();
});
// 寿命切れ・画面外を除去
particles = particles.filter(p => p.life > 0 && p.y < canvas.height + 40);
if (particles.length) {
rafId = requestAnimationFrame(loop);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
rafId = null;
}
};
let done = false;
btn.addEventListener('click', () => {
if (done) return;
done = true;
btn.classList.add('is-done');
const label = btn.querySelector('.cta__label');
if (label) label.textContent = '完了!';
if (!reduce) spawn(); // モーション抑制時は紙吹雪なし
// デモを繰り返せるよう少し後に初期状態へ戻す
setTimeout(() => {
btn.classList.remove('is-done');
if (label) label.textContent = '購入する';
done = false;
}, 2600);
});
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。