DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Confetti isn't an animation — it's an array of paper scraps, each a bag of numbers one loop nudges under gravity every frame

A confetti burst — the party popper, the level-up, the checkout "success!" — looks like something you'd reach for a library for. It's really the simplest kind of physics there is: a particle system. Not one animation but hundreds of independent little objects, each with a position, a velocity, a spin and a countdown to death, advanced by one loop and painted on one canvas. I built it from scratch in about a hundred lines, no dependencies. Here's how it holds together.

One particle is just a bag of numbers

There's no confetti object — only many identical records. A particle carries a position, a velocity, a rotation and a spin, a life that counts down, plus size, colour, shape, and a wobble phase that drives its sideways flutter. The whole system is one array of these.

function addParticle(x, y, vx, vy){
  if (parts.length >= MAX) return;            // hard cap
  const life = cfg.lifetime * rnd(0.7, 1.15);
  parts.push({ x, y, vx, vy,
    rot: rnd(0, Math.PI*2), vrot: rnd(-0.2, 0.2),
    wob: rnd(0, Math.PI*2), wobSpeed: rnd(0.05, 0.12), drift: rnd(0.2, 0.9),
    size: rnd(6,12), life, maxLife: life,
    color: pick(PALETTE), shape: pickShape() });
}
Enter fullscreen mode Exit fullscreen mode

The rAF loop — and time-normalise the step

requestAnimationFrame hands you a timestamp once per repaint. Screens tick at 60 or 144Hz and a backgrounded tab pauses entirely, so I never assume "one frame = one unit". I measure the delta, divide by an ideal 16.67ms frame to get dt, and multiply every physics term by it — the motion is identical at any refresh rate. Clamp dt so a long pause doesn't teleport everything off-screen.

let dt = (now - last) / 16.667;   // 1.0 at 60fps, 0.5 at 120fps
if (dt > 3) dt = 3;               // tab was hidden -> don't teleport
Enter fullscreen mode Exit fullscreen mode

The integrator is five lines

This is the entire physics. Gravity adds to the downward velocity each frame, so the fall accelerates. A little air drag bleeds sideways speed. Drift is a sine wave on the wobble phase, added to x, that makes paper sway instead of dropping straight. Advance position by velocity, rotation by spin, shave dt off life — and cull anything whose life hit zero or that fell off the bottom. Loop backwards so removals don't skip anyone.

for (let i = parts.length - 1; i >= 0; i--){
  const p = parts[i];
  p.vy += cfg.gravity * dt;                       // gravity accelerates
  p.vx *= Math.pow(0.995, dt);                    // gentle air drag
  p.wob += p.wobSpeed * dt;
  p.x += (p.vx + Math.cos(p.wob)*p.drift) * dt;   // drift = flutter
  p.y += p.vy * dt;
  p.rot += p.vrot * dt;  p.life -= dt;
  if (p.life <= 0 || p.y > H+40){ parts.splice(i,1); continue; }
  drawPart(p);
}
Enter fullscreen mode Exit fullscreen mode

Draw a rotated, fading scrap

To paint one: save the context, set globalAlpha from life/maxLife so it fades, translate to the particle, rotate by its angle, then draw at the origin. A rect whose height is scaled by cos(wobble) looks like paper flipping edge-on; a circle is a dot; a ribbon is a long thin strip. Always restore so the next particle starts clean.

Three modes, one spawn

A burst fires count particles from one origin at random angles around the full circle — a party popper. A cannon is the same spawn with a constrained angle: a corner origin and a cone of ±spread/2, fired faster so paper crosses the stage. Rain is a steady drip — because a rate like "0.9 per frame" is fractional, I accumulate it and spawn whole particles when the accumulator crosses 1, keeping it frame-rate honest.

Why canvas, not DOM

The whole reason: hundreds of <div>s each need layout, style and their own composited layer, and the browser buckles past a few hundred. A canvas is one element — drawing 500 scraps is 500 cheap fill calls on a single surface, so it holds 60fps. Add a particle cap so a mash of the button can't grow the array forever, clear the whole frame each tick (a full redraw is cheaper than tracking dirty regions), and honour prefers-reduced-motion by spawning fewer. The live read-out is a genuine stress test of that choice: push the count to 400, hammer Burst, and watch it hold 60fps while the alive count spikes and drains. Press Burst, fire the cannons, toggle Rain, and click anywhere to launch from that exact point:

https://dev48v.infy.uk/design/day50-confetti.html

Top comments (0)