컨페티

Confetti

성공·축하 순간에 색종이 조각이 사방으로 튀어 오르며 중력에 끌려 떨어지는 효과. 결제 완료, 목표 달성 화면에서 자주 봅니다.

다른 이름: Celebration burstParticle burst
···
html
<div class="cft">
  <button class="cbtn" id="cbtn">&#127881; Celebrate</button>
  <div class="field" id="cfield"></div>
</div>
css
.fx-dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--accent);box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 18%,transparent),0 2px 6px rgba(0,0,0,.3);
  pointer-events:none;z-index:9999;transition:opacity .25s}
.cft{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden}
.cbtn{position:relative;z-index:2;padding:12px 22px;border:none;border-radius:999px;background:var(--accent);
  color:#fff;font-weight:700;font-size:clamp(12px,3.6vmin,15px);cursor:pointer;box-shadow:0 10px 22px color-mix(in srgb,var(--accent) 35%,transparent)}
.field{position:absolute;inset:0;pointer-events:none}
.piece{position:absolute;width:8px;height:12px;top:50%;left:50%;opacity:1}
js
const fxDot=document.createElement('div');fxDot.className='fx-dot';document.body.appendChild(fxDot);
let fxAuto=true,fxX=innerWidth/2,fxY=innerHeight/2;
function fxHide(){fxDot.style.opacity='0';}
addEventListener('pointerdown',()=>{fxAuto=false;fxHide();},{capture:true});
addEventListener('pointermove',e=>{fxAuto=false;fxHide();fxX=e.clientX;fxY=e.clientY;},{capture:true});
function fxWalk(x,y,ms){
  return new Promise(res=>{
    const sx=fxX,sy=fxY,start=performance.now();
    function step(now){
      if(!fxAuto)return res();
      const p=Math.min(1,(now-start)/ms),e=1-Math.pow(1-p,3);
      fxX=sx+(x-sx)*e;fxY=sy+(y-sy)*e;
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      if(p<1)requestAnimationFrame(step);else res();
    }
    requestAnimationFrame(step);
  });
}
function fxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!fxAuto)return res(); if(now-start<ms)requestAnimationFrame(step);else res(); }
    requestAnimationFrame(step);
  });
}

const cbtn=document.getElementById('cbtn');
const field=document.getElementById('cfield');
const colors=['var(--accent)','var(--accent-2)','var(--accent-3)','#ffd166'];
function burst(x,y){
  for(let i=0;i<26;i++){
    const p=document.createElement('div');
    p.className='piece';
    p.style.background=colors[i%colors.length];
    p.style.left=x+'px'; p.style.top=y+'px';
    const ang=Math.random()*Math.PI*2, speed=2+Math.random()*4;
    const vx=Math.cos(ang)*speed, vy=Math.sin(ang)*speed-3;
    field.appendChild(p);
    let t=0;
    (function anim(){
      t+=1;
      const life=t/50;
      const dx=vx*t, dy=vy*t+0.15*t*t;
      p.style.transform='translate('+dx+'px,'+dy+'px) rotate('+(t*14)+'deg)';
      p.style.opacity=String(Math.max(0,1-life));
      if(life<1) requestAnimationFrame(anim); else p.remove();
    })();
  }
}
cbtn.addEventListener('pointerdown',()=>{
  fxAuto=false; fxHide();
  const r=cbtn.getBoundingClientRect();
  burst(r.left+r.width/2, r.top);
});
async function autoSeq(){
  while(fxAuto){
    const r=cbtn.getBoundingClientRect();
    await fxWalk(r.left+r.width/2, r.top+r.height/2, 600); if(!fxAuto) break;
    burst(r.left+r.width/2, r.top);
    await fxWait(1600);
  }
}
autoSeq();

조각마다 무작위 각도(`angle`)와 속도(`speed`)로 초기 속도 벡터(`vx, vy`)를 정하고, 매 프레임 `x += vx`, `y += vy + gravity * t²`로 위치를 갱신한다 — 포물선 운동을 흉내 내는 아주 단순한 물리다. 동시에 `rotate`를 계속 증가시키면 빙글빙글 도는 조각처럼 보인다.

경과 시간에 비례해 `opacity`를 0으로 줄여서, 화면 밖으로 나가기 전에 자연스럽게 옅어지며 사라지게 한다. 수명이 다한(`opacity` 0 또는 일정 프레임 경과) 조각은 DOM에서 반드시 제거해야 메모리가 계속 쌓이지 않는다.

조각이 20~30개를 넘어가면 DOM 요소 방식은 버벅일 수 있다. 대량으로 자주 터뜨려야 한다면 `<canvas>`에 그리는 라이브러리(canvas-confetti 등)로 옮기는 것이 안전하다.

언제 쓰나

결제 완료, 온보딩 완주, 업적 달성처럼 명확한 성공 순간에 한 번만 씁니다. 자주 반복되면 축하의 의미가 옅어집니다.