Confetti

Confetti celebrates one thing: the moment a group is fully settled. Particles burst from the center of the stage, arc upward with gravity, and fade over 1.4–2.2 seconds. It runs exactly once per settle event — it's never looped, never decorative, and never triggered for partial settlements.

Variants

Settle completion — the only use case
All settled up ✓
Goa trip · 4 people · ₹18,460
On dark surface
All settled up ✓
Farzi Cafe · 6 people · ₹5,040

The confetti system is a <canvas> element that lives inside the settle stage — absolute-positioned, pointer-events none, and sized to fill its parent. On trigger, 20–30 particles burst from the horizontal center of the stage at approximately 60% height (roughly where a settle button sits). Particles arc upward and fall with gravity. Each particle is a small rectangle or circle, randomly assigned from the Settld palette.

The animation runs to completion on its own. There is no stop() API — particles simply fade as their lifetime expires. Duration per particle: 1.4–2.2 seconds. Total animation: under 2.5 seconds on any device.

Physics parameters

ParameterValueNotes
Particle count20–30 per burstRandom in range. Lower than the landing's 90-particle hero burst — this is an in-app moment, not a hero demo.
Initial speed180–340 px/sHorizontal variance ±60° from upward vertical
Gravity480 px/s²Enough to bring particles back down within the stage
Air dragvx × 0.99 per frameSlows horizontal drift gradually
Particle lifetime1.4 – 2.2 sRandomised per particle. Fade: linear alpha on life / ttl
Particle size4 – 9 pxMix of rect and circle shapes
RotationRandom initial, ±4 rad/sRect particles spin; circles don't need rotation

Particle colors

Teal
Coral
Butter
Ink
Teal light

Five colors, randomly assigned per particle: #0E7C66 (teal), #E76F51 (coral), #F4C94E (butter), #171513 (ink), #5fc7a8 (light teal). The palette is the same on light and dark surfaces — the particle colors have enough saturation to read against both --s-paper and --dark-canvas.

States

StateCanvasNotes
IdleEmpty, transparentCanvas is always present in the DOM — invisible until burst
BurstingParticles animatingrAF loop runs. Canvas is rendered at device pixel ratio (max 2×)
DoneEmpty, transparentrAF loop stops when all particles expire. Canvas auto-clears.

On dark surfaces

No code change is needed for dark surfaces. The five particle colors — teal, coral, butter, ink, and light teal — all have enough saturation to read against --dark-canvas (#1E1A16). The ink particle (#171513) is the only edge case: on very dark backgrounds it blends in slightly, but this is acceptable since the other four colors dominate visually. If needed, substitute ink with --s-paper (#FBF8F0) for dark surfaces.

Anatomy

· · ✦ · · · ✦ · ·
· ✦ · · ✦ · · · ✦
✦ · · · · ✦ · ✦ ·
All settled up ✓
Goa trip · ₹18,460
Burst origin (center, 60% height)
① StageRelative-positioned container. overflow: hidden clips particles at edges.
② CanvasAbsolute inset-0, pointer-events none. Resized on stage resize. DPR ≤ 2×.
③ Particles20–30 rect/circle shapes, branded palette, random angle spread ±60° from up.
④ Physics looprAF. dt-capped at 40ms. Gravity 480 px/s². Alpha fades linearly on life/ttl.

Usage

Do Fire confetti exactly once when the last balance in a group reaches zero. This is the app's signature moment — treat it as a reward. The same trigger should also update the settle button state and show the "All settled up ✓" headline.
Don't Don't use confetti for partial settlements ("Rohan settled up" within a group that still has other balances). Don't loop it. Don't use it in background tasks or notifications. Confetti must require the user's full attention.
Do Respect prefers-reduced-motion. When the user has reduced motion enabled, skip the particle animation entirely. The "All settled up ✓" headline and state change are sufficient feedback on their own.
Don't Don't add confetti to non-settle moments: adding a new expense, joining a group, or hitting a usage cap. The animation is meaningful only because it's rare. Overuse destroys the signal.

Accessibility

Reduced motion Check window.matchMedia('(prefers-reduced-motion: reduce)').matches before calling burst(). If true, skip the animation — the state change (button text, headline) is the confirmation. Never fire particles when the user has opted out of motion.
Canvas The <canvas> element has no semantic role — it's purely visual decoration. Add aria-hidden="true" so screen readers ignore it. The "All settled up" announcement should come from an aria-live="polite" region, not from the canvas.
Performance Cap device pixel ratio at 2× (Math.min(window.devicePixelRatio, 2)). With 20–30 particles, the animation runs well under 16ms per frame on all modern devices. Cancel any pending rAF when the component unmounts.

Code

HTML

<!-- Stage wraps both the canvas and the settle UI -->
<div class="confetti-stage" id="settle-stage">
  <canvas class="confetti-canvas" id="confetti-canvas"
          aria-hidden="true"></canvas>
  <!-- settle content here -->
  <p aria-live="polite" id="settle-announce"></p>
</div>

JavaScript

function createConfetti(stageEl, canvasEl) {
  const ctx = canvasEl.getContext('2d');
  let W, H, particles = [], rafId = null, lastT = 0;
  const COLORS = ['#0E7C66', '#E76F51', '#F4C94E', '#171513', '#5fc7a8'];

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const r = stageEl.getBoundingClientRect();
    W = r.width; H = r.height;
    canvasEl.width  = W * dpr;
    canvasEl.height = H * dpr;
    canvasEl.style.width  = W + 'px';
    canvasEl.style.height = H + 'px';
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  function burst() {
    // Respect reduced motion
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

    const cx = W / 2, cy = H * 0.6;
    const count = 20 + Math.floor(Math.random() * 11); // 20–30
    const spread = Math.PI * 2 / 3; // ±60° from straight up
    const baseAngle = -Math.PI / 2; // straight up

    for (let i = 0; i < count; i++) {
      const angle = baseAngle + (Math.random() - 0.5) * spread;
      const speed = 180 + Math.random() * 160;
      particles.push({
        x: cx, y: cy,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        g:  480 + Math.random() * 120,
        life: 0,
        ttl:  1.4 + Math.random() * 0.8,
        size: 4 + Math.random() * 5,
        rot:  Math.random() * Math.PI * 2,
        vr:   (Math.random() - 0.5) * 8,
        color: COLORS[Math.floor(Math.random() * COLORS.length)],
        shape: Math.random() < 0.55 ? 'rect' : 'circle'
      });
    }
    if (!rafId) { lastT = performance.now(); rafId = requestAnimationFrame(tick); }
  }

  function tick(now) {
    const dt = Math.min((now - lastT) / 1000, 0.04);
    lastT = now;
    ctx.clearRect(0, 0, W, H);
    particles = particles.filter(p => {
      p.life += dt;
      if (p.life >= p.ttl) return false;
      p.vy += p.g * dt;
      p.vx *= 0.99;
      p.x  += p.vx * dt;
      p.y  += p.vy * dt;
      p.rot += p.vr * dt;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rot);
      ctx.globalAlpha = Math.max(0, 1 - p.life / p.ttl);
      ctx.fillStyle = p.color;
      if (p.shape === 'rect') {
        ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.6);
      } else {
        ctx.beginPath();
        ctx.arc(0, 0, p.size * 0.4, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.restore();
      return true;
    });
    if (particles.length) { rafId = requestAnimationFrame(tick); }
    else { rafId = null; }
  }

  return { burst, destroy: () => window.removeEventListener('resize', resize) };
}

Usage

const stage  = document.getElementById('settle-stage');
const canvas = document.getElementById('confetti-canvas');
const confetti = createConfetti(stage, canvas);

// Call on settle completion
settleButton.addEventListener('click', () => {
  markGroupSettled();                 // your settle logic
  confetti.burst();                   // fire particles
  announce.textContent = 'All settled up!'; // aria-live update
});

CSS classes

ClassPurpose
.confetti-stageRelative-positioned container with overflow: hidden — clips particles at edges
.confetti-canvasAbsolute inset-0 canvas, pointer-events none, auto-sized to stage

Design tokens used

Token / valueRole
#0E7C66 (--teal)Primary particle color — highest frequency
#E76F51 (--coral)Particle color
#F4C94E (--butter)Particle color
#171513 (--ink)Particle color — dark anchor
#5fc7a8Light teal particle — softens the burst palette
--d-fast (140ms)Not used by particles — but used by the settle button that triggers the burst