03Burst scatter — pieces scatter from the click point at differing angle values before fading
Starting from the click point, several pieces spread out each with a different angle value and speed value, and their opacity value drops over time until they reach a faded state.
Best forA purchase-complete button, an event-participation button
GRAVITY=420COLORSburst

<main class="stage">
<div class="ctacfb is-demo" id="ctacfb">
<div class="ctacfb__panel">
<div class="ctacfb__wrap">
<canvas class="ctacfb__canvas" id="ctacfbCanvas" width="220" height="120"></canvas>
<button class="ctacfb__btn" id="ctacfbBtn" type="button">Complete purchase</button>
</div>
<span class="ctacfb__hint">Click to scatter pieces in every direction</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(중력·감쇠·회전)은 JS 캔버스에 있다 — 여기는 정적 배치만.
@include stage($stage-orange);
.stage { position: relative; }
.ctacfb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctacfb__wrap {
position: relative;
width: 220px;
height: 120px;
display: grid;
place-items: center;
}
.ctacfb__canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.ctacfb__btn {
position: relative;
z-index: 1;
border: 0;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: $sp-3 $sp-5;
cursor: pointer;
}
.ctacfb__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctacfb__panel { padding: $sp-4; gap: $sp-2; }
.ctacfb__wrap { width: 180px; height: 100px; }
.ctacfb__btn { padding: $sp-2 $sp-4; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ctacfb__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctacfb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctacfb__wrap {
position: relative;
width: 220px;
height: 120px;
display: grid;
place-items: center;
}
.ctacfb__canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.ctacfb__btn {
position: relative;
z-index: 1;
border: 0;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 15px;
padding: 12px 20px;
cursor: pointer;
}
.ctacfb__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctacfb__panel {
padding: 16px;
gap: 8px;
}
.ctacfb__wrap {
width: 180px;
height: 100px;
}
.ctacfb__btn {
padding: 8px 16px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctacfb__btn {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctacfb');
var btn = document.getElementById('ctacfbBtn');
var canvas = document.getElementById('ctacfbCanvas');
var ctx = canvas.getContext('2d');
var COLORS = ['#5b8cf8', '#ff9ebb', '#4cd4a6', '#b9a0ff'];
// 계산 겹: 조각마다 각도·속도 값을 난수로 부여하고, 매 프레임 중력(GRAVITY)을 속도에
// 더하고 속도에 감쇠(DRAG)를 곱해 궤적을 계산한다. 회전 값도 각자 다른 속도로 누적하고
// 투명도 값은 남은 수명 비율로 계산해 사라진다.
var GRAVITY = 420, DRAG = 0.985, LIFE = 900;
var particles = [];
var raf = null, lastT = 0;
function burst() {
var cx = canvas.width / 2, cy = canvas.height * 0.35;
particles = [];
for (var i = 0; i < 18; i++) {
var angle = (Math.PI * 2 * i) / 18 + (Math.random() - 0.5) * 0.5;
var speed = 90 + Math.random() * 110;
particles.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
rot: Math.random() * Math.PI,
vrot: (Math.random() - 0.5) * 10,
color: COLORS[i % COLORS.length],
age: 0
});
}
if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(tick); }
}
function tick(now) {
var dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
ctx.clearRect(0, 0, canvas.width, canvas.height);
var alive = false;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
p.age += dt * 1000;
if (p.age >= LIFE) { continue; }
alive = true;
p.vy += GRAVITY * dt;
p.vx *= DRAG; p.vy *= DRAG;
p.x += p.vx * dt; p.y += p.vy * dt;
p.rot += p.vrot * dt;
var op = Math.max(0, 1 - p.age / LIFE);
ctx.save();
ctx.globalAlpha = op;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
ctx.fillRect(-4, -3, 8, 6);
ctx.restore();
}
if (alive) {
raf = requestAnimationFrame(tick);
} else {
raf = null;
}
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); burst(); });
}
wireReal();
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoplayTimer = setInterval(burst, 1100); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnConfettiBurst.module.scss";
type Particle = {
x: number; y: number; vx: number; vy: number;
rot: number; vrot: number; color: string; age: number;
};
export type CtaBtnConfettiBurstProps = {
color?: string;
};
export default function CtaBtnConfettiBurst({
color = "#0a4fe8",
}: CtaBtnConfettiBurstProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const canvas = root.querySelector<HTMLCanvasElement>(`.${styles.canvas}`);
if (!btn || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const COLORS = ["#5b8cf8", "#ff9ebb", "#4cd4a6", "#b9a0ff"];
// 계산 겹: 조각마다 각도·속도 값을 난수로 부여하고, 매 프레임 중력(GRAVITY)을 속도에
// 더하고 속도에 감쇠(DRAG)를 곱해 궤적을 계산한다. 회전 값도 각자 다른 속도로 누적하고
// 투명도 값은 남은 수명 비율로 계산해 사라진다.
const GRAVITY = 420, DRAG = 0.985, LIFE = 900;
let particles: Particle[] = [];
let raf: number | null = null;
let lastT = 0;
function burst() {
const cx = canvas!.width / 2, cy = canvas!.height * 0.35;
particles = [];
for (let i = 0; i < 18; i++) {
const angle = (Math.PI * 2 * i) / 18 + (Math.random() - 0.5) * 0.5;
const speed = 90 + Math.random() * 110;
particles.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
rot: Math.random() * Math.PI,
vrot: (Math.random() - 0.5) * 10,
color: COLORS[i % COLORS.length],
age: 0,
});
}
if (raf === null) { lastT = performance.now(); raf = requestAnimationFrame(tick); }
}
function tick(now: number) {
const dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
ctx!.clearRect(0, 0, canvas!.width, canvas!.height);
let alive = false;
for (const p of particles) {
p.age += dt * 1000;
if (p.age >= LIFE) continue;
alive = true;
p.vy += GRAVITY * dt;
p.vx *= DRAG; p.vy *= DRAG;
p.x += p.vx * dt; p.y += p.vy * dt;
p.rot += p.vrot * dt;
const op = Math.max(0, 1 - p.age / LIFE);
ctx!.save();
ctx!.globalAlpha = op;
ctx!.translate(p.x, p.y);
ctx!.rotate(p.rot);
ctx!.fillStyle = p.color;
ctx!.fillRect(-4, -3, 8, 6);
ctx!.restore();
}
raf = alive ? requestAnimationFrame(tick) : null;
}
function onClick() { stopAutoplay(); burst(); }
btn.addEventListener("click", onClick);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoplayTimer = window.setInterval(burst, 1100); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<div className={styles.wrap}>
<canvas className={styles.canvas} width={220} height={120} />
<button className={styles.btn} type="button" style={{ background: color }}>구매 완료</button>
</div>
<span className={styles.hint}>클릭하면 조각이 사방으로 흩어집니다</span>
</div>
</div>
);
}03 Burst scatter — pieces scatter from the click point at differing angle values before fading — Measured area 3.59% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · CTA Button Design: 9 Interaction Patterns
The post this demo lives in
CTA Button Design: 9 Interaction PatternsOne shadow length value grows and shrinks with hover state, one perimeter progress value depletes to zero over time, and See all demos in this post
If you like this effect - similar demos
Buttons01 Ink-diffusion Button That Spreads From the Click PointButton Click Effect CSS: 9 Canvas Pixel Patterns
Buttons03 Icon ripple spreading from the exact press point9 Icon Button CSS Patterns — Press, Drag, Expand
Buttons05 Hanging-angle swing — a value hung from a top point swings its angle value side to side while the swing shrinks over timeButton Press Animation UI: 9 Calculation Patterns