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
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
| Parameter | Value | Notes |
|---|---|---|
| Particle count | 20–30 per burst | Random in range. Lower than the landing's 90-particle hero burst — this is an in-app moment, not a hero demo. |
| Initial speed | 180–340 px/s | Horizontal variance ±60° from upward vertical |
| Gravity | 480 px/s² | Enough to bring particles back down within the stage |
| Air drag | vx × 0.99 per frame | Slows horizontal drift gradually |
| Particle lifetime | 1.4 – 2.2 s | Randomised per particle. Fade: linear alpha on life / ttl |
| Particle size | 4 – 9 px | Mix of rect and circle shapes |
| Rotation | Random initial, ±4 rad/s | Rect particles spin; circles don't need rotation |
Particle colors
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
| State | Canvas | Notes |
|---|---|---|
| Idle | Empty, transparent | Canvas is always present in the DOM — invisible until burst |
| Bursting | Particles animating | rAF loop runs. Canvas is rendered at device pixel ratio (max 2×) |
| Done | Empty, transparent | rAF 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
overflow: hidden clips particles at edges.Usage
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.
Accessibility
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> 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.
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
| Class | Purpose |
|---|---|
.confetti-stage | Relative-positioned container with overflow: hidden — clips particles at edges |
.confetti-canvas | Absolute inset-0 canvas, pointer-events none, auto-sized to stage |
Design tokens used
| Token / value | Role |
|---|---|
#0E7C66 (--teal) | Primary particle color — highest frequency |
#E76F51 (--coral) | Particle color |
#F4C94E (--butter) | Particle color |
#171513 (--ink) | Particle color — dark anchor |
#5fc7a8 | Light teal particle — softens the burst palette |
--d-fast (140ms) | Not used by particles — but used by the settle button that triggers the burst |