03조각 발산 — 클릭한 자리에서 여러 조각이 저마다 다른 각도 값으로 흩어졌다가 사라지는 버튼
클릭이 일어난 지점을 기준으로 여러 조각이 저마다 다른 각도 값과 속도 값으로 퍼져나가고, 투명도 값이 시간이 지날수록 낮아지다 사라진 상태가 됩니다.
쓰임구매 완료 버튼, 이벤트 참여 버튼
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">구매 완료</button>
</div>
<span class="ctacfb__hint">클릭하면 조각이 사방으로 흩어집니다</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 조각 발산 — 클릭한 자리에서 여러 조각이 저마다 다른 각도 값으로 흩어졌다가 사라지는 버튼 — 측정 누적면적 3.59% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · CTA 버튼 디자인 9종 — 그림자가 늘어나는 것부터 막대가 튀는 것까지
이 데모가 속한 글
CTA 버튼 디자인 9종 — 그림자가 늘어나는 것부터 막대가 튀는 것까지cta 버튼 디자인은 클릭을 부르는 버튼에 상태 변화를 계산해 얹는 작업인데, 아홉 개 중 하나는 그림자가 뻗어나가는 길이 값이 호버 상태에 따라 늘고 줄고, 하나는 둘레를 감싼 진행 값이 정해진 시간 동안 0까지 이 글의 데모 전부 보기

