AtmosphereFree

Confetti Explosion

A radial Canvas 2D burst of metallic confetti: 260 pieces in 4 waves, 3D-normal lighting with glints, a 2-3 s shower, Δt-normalised physics and a ConfettiExplosion.burst() API.

ConfettiCanvas 2DExplosionCélébration
Hover or click the scene to interact

This effect is free — the Atmosphere category contains 57 effects total, including 10 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

① Gamification — Badge unlocked

WhenThe user completes a course, challenge, or streak and clicks 'Claim my badge' — the radial burst fires from the button and floods the frame with golden confetti.
WhyThe large dark surface lets the golden confetti burst and fall freely — nothing competes with the effect, and the medal stays readable thanks to the contrast.
SettingsGold palette (#FFD700, #FFF0A0, #FFA500, #FFFFFF, #FDFFB6), count 260, power 1.0. 4 waves over 148 ms from the bottom-center button. DRAG 0.96, G 0.06.

② Victory — End of round

WhenThe round ends and the results screen appears — tournament, multiplayer quiz, level completion. The burst fires automatically on viewport entry (180 ms delay).
WhyThe auto-burst on viewport entry removes the need to click on a results screen — confetti explode on display and fill all visible space. Clicking the scene fires a second 160-particle burst.
SettingsDefault candy palette (#FF4D9E, #FFD700, #00E5FF, #8AFF59, #FF8040, #E040FB), count 260, auto-burst on. Both depth layers (alpha 0.70 / 1.00) create parallax depth.

③ Quiz complete — Perfect score

WhenThe user finishes a quiz without a single mistake. The results page reveals the score and the Share button fires the burst from the bottom of the scene.
WhyDark background vs rainbow confetti makes the radial explosion maximally legible. Curved ribbons and stars add shape variety that quiz and learning apps appreciate.
SettingsRainbow palette (#FF0000, #FF8800, #FFFF00, #00FF00, #0055FF, #AA00FF, #FF0099, #FFFFFF), count 260, power 1.0. 9-segment ribbons, specular glint, sparkles at idle.

How it works

The IIFE creates one instance per .cxp-scene found on the page. Each instance maintains a local pool ≤ 550 particles, 12 permanent floating confetti that drift slowly downward in a loop, and 14 sparkles that twinkle between bursts. A single requestAnimationFrame per instance drives rendering; an IntersectionObserver (threshold 0.15) starts and stops the loop based on visibility; a ResizeObserver recalibrates the canvas on resize, accounting for devicePixelRatio.

Each burst fires particles in 4 waves spread over 148 ms — 30% at 0 ms, 28% at 42 ms, 25% at 90 ms, 17% at 148 ms — to avoid a visible spawn spike. Then physics takes over: air drag 0.96 per frame, gravity 0.06, lateral wobble 0.32 and slowing spin — pieces drift back down over 2–3 s.

Four shapes mix at random: rectangles, circles, curved ribbons (9-segment quadratic Bézier curves) and 5-point stars. Each particle simulates a 3D normal via rotX × rotY: face toward the viewer → main color; opposite face → shade darkened by 30% (darken(hex, 0.30)). A specular glint (~92% intensity) briefly shifts the color toward white. Physics is Δt-normalised: drag DRAG = 0.96 per frame @ 60fps (compensated as DRAG^dt), gravity G = 0.06 px/frame², lateral wobble WOB = 0.32. Particles live 144–204 frames and fade in the last 25% of their life.

In idle mode, floaters drift and sparkles twinkle. An automatic burst fires on viewport entry (180 ms delay) and repeats every 7 s with no user click. In prefers-reduced-motion, a single gentle burst (20 particles, power = 0.50) fires on entry then all looping stops — floaters and auto-repeat included.

Accessibility

  • prefers-reduced-motion: if reduced, a single gentle burst (20 particles, power = 0.50) fires on viewport entry then all animation stops. The 12 permanent floaters and the 7-second auto-repeat are also disabled.
  • The scene carries role="img" and a descriptive aria-label; the canvas is marked aria-hidden="true" — graphical elements do not feed the accessibility tree.
  • The trigger is a native <button class="cxp-btn">, keyboard-focusable (Tab / Enter / Space), with a golden :focus-visible ring (#fbbf24).
  • Clicks outside the button fire a burst at the exact click point (160 particles) — the keyboard is never required to reach the main functionality.

Browser compatibility

Requires Canvas 2D, requestAnimationFrame, IntersectionObserver and ResizeObserver — available in all modern browsers. Zero external dependencies.

Chrome 88+✓ Full
Firefox 78+✓ Full
Safari 15+✓ Full
Edge 88+✓ Full
Mobile iOS 15+✓ Touch OK
Android Chrome✓ Full

Without Canvas 2D support, the scene stays empty (no fatal JS errors); the button and any overlaid text remain readable. Without IntersectionObserver (IE 11), the effect starts immediately on initialisation.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="cxp-scene" id="cxpScene"
           role="img"
           aria-label="Confetti Explosion — explosion radiale de confettis métallisés depuis le bouton"
           data-colors="#FF4D9E,#FFD700,#00E5FF,#8AFF59,#F0F4F8,#FF8040,#E040FB,#FDFFB6">
        <canvas class="cxp-canvas" aria-hidden="true"></canvas>
        <div class="cxp-label" aria-hidden="true">Clic · ou</div>
        <button class="cxp-btn" aria-label="Lancer une explosion de confettis">🎉 Celebrate!</button>
      </div>
CSS
/* -- Scène -- */
.cxp-scene {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: 14px;
  overflow: hidden;
  isolation: isolate;
  cursor: crosshair;
  contain: layout style paint;
  background: #0a0a0f;
}

/* -- Canvas -- */
.cxp-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  display: block;
}

/* -- Bouton Celebrate (dans la démo, centré) -- */
.cxp-btn {
  position: absolute;
  bottom: 50%;
  left: 50%;
  transform: translate(-50%, 50%);
  z-index: 2;
  background: rgba(255, 255, 255, 0.10);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: #f1f5f9;
  padding: 10px 26px;
  border-radius: 28px;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: background 0.14s, transform 0.10s;
  outline: 2px solid transparent;
  letter-spacing: 0.02em;
  white-space: nowrap;
  /* léger scintillement résiduel entre explosions */
  animation: cxp-idle-pulse 3.4s ease-in-out infinite;
}
.cxp-btn:hover  { background: rgba(255,255,255,0.18); }
.cxp-btn:active { transform: translate(-50%, 50%) scale(0.95); }
.cxp-btn:focus-visible { outline-color: #fbbf24; outline-offset: 3px; }

@keyframes cxp-idle-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
  50%       { box-shadow: 0 0 8px 2px rgba(251,191,36,0.18); }
}

/* -- Label bas-gauche -- */
.cxp-label {
  position: absolute;
  bottom: 10px;
  left: 14px;
  pointer-events: none;
  z-index: 1;
  color: rgba(148, 163, 184, 0.28);
  font-size: clamp(9px, 1.2vw, 11px);
  letter-spacing: 0.10em;
  text-transform: uppercase;
  font-weight: 500;
  line-height: 1.5;
}

/* -- Punch bouton au clic -- */
.cxp-btn.cxp-punch {
  animation: cxp-btn-punch 0.30s cubic-bezier(0.36, 0.07, 0.19, 0.97) forwards;
}
@keyframes cxp-btn-punch {
  0%   { transform: translate(-50%, 50%) scale(0.92); }
  55%  { transform: translate(-50%, 50%) scale(1.05); }
  100% { transform: translate(-50%, 50%) scale(1.00); }
}

/* -- Contraste fort (toggle review) -- */
.cxp-scene.cxp-hc { filter: contrast(1.45) brightness(1.12); }

/* -- Carte objectif (exemple d'usage) -- */
.cxp-goal-scene {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: 14px;
}

.cxp-goal-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2;
  pointer-events: none;
}

.cxp-goal-card {
  background: rgba(15, 15, 22, 0.88);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 18px;
  padding: clamp(20px,4%,32px) clamp(24px,5%,44px);
  text-align: center;
  pointer-events: all;
  box-shadow: 0 8px 32px rgba(0,0,0,0.60);
  min-width: 220px;
}

.cxp-goal-icon   { font-size: 36px; line-height: 1; margin-bottom: 8px; }
.cxp-goal-title  { margin: 0 0 4px; font-size: clamp(15px,2.2vw,19px); font-weight: 700; color: #f1f5f9; letter-spacing: -0.02em; }
.cxp-goal-sub    { margin: 0 0 18px; font-size: clamp(11px,1.4vw,13px); color: #52525b; }

.cxp-goal-btn {
  background: linear-gradient(135deg, #ec4899, #f59e0b);
  border: none; color: #fff; padding: 10px 26px; border-radius: 10px;
  font-size: clamp(12px,1.5vw,14px); font-weight: 700; cursor: pointer;
  transition: opacity 0.14s, transform 0.10s;
  outline: 2px solid transparent; letter-spacing: 0.01em;
}
.cxp-goal-btn:hover  { opacity: 0.88; }
.cxp-goal-btn:active { transform: scale(0.96); }
.cxp-goal-btn:focus-visible { outline-color: #fbbf24; outline-offset: 2px; }

/* -- prefers-reduced-motion -- */
@media (prefers-reduced-motion: reduce) {
  .cxp-btn { animation: none; transition: none; }
  .cxp-btn.cxp-punch { animation: none; }
  .cxp-btn:active { transform: translate(-50%, 50%); }
  .cxp-goal-btn   { transition: none; }
  .cxp-goal-btn:active { transform: none; }
}

/* -- prefers-reduced-transparency -- */
@media (prefers-reduced-transparency: reduce) {
  .cxp-goal-card { background: #0f0f16; border-color: #3f3f46; }
}

/* -- Responsive mini -- */
@media (max-width: 480px) {
  .cxp-goal-card { padding: 16px 18px; min-width: 180px; }
  .cxp-btn { font-size: 13px; padding: 8px 18px; }
}
JavaScript (fx-0505)
(function () {
  'use strict';

  var REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var PI2 = Math.PI * 2;
  var PI  = Math.PI;

  /* ── Physique (valeurs Owen) ──────────────────────────────────────────── */
  var G    = 0.06;   /* gravité px/frame² @ 60fps → terminal ≈ 1,44 px/frame */
  var DRAG = 0.96;   /* traînée ×0,96/frame (Owen : 0,955–0,965) */
  var WOB  = 0.32;   /* flottement feuille-morte */

  /* Δt normalisé (60fps=1,0 ; 120fps=0,5) — recalculé dans tick() */
  var _tickDt = 1.0;
  var _lastTickTs = 0;

  /* ── Vitesses initiales absolues par couche (px/frame @ 60fps) ──────── */
  /* rayon max ≈ 40 % de la largeur (Owen) : 8/0,04 ≈ 200 px ≈ 42 % de 480 */
  var SPEED = [
    { spMin: 2, spMax:  5, alpha: 0.70 },   /* 0 = lointain */
    { spMin: 4, spMax:  8, alpha: 1.00 }    /* 1 = proche   */
  ];

  /* ── Biais vers le haut (px/frame soustrait à vy) ────────────────────── */
  var BIAS_UP = 0;   /* annulé : décalait toutes les particules vers le haut, sortie par le top */

  /* ── Tailles (px, non scalées) ───────────────────────────────────────── */
  var SZ_NEAR = { rw:[8,14],  rh:[3.5,6.5], c:[4,10],  s:[6,13]  };
  var SZ_FAR  = { rw:[4, 7],  rh:[1.8,3.2], c:[2, 5],  s:[3, 7]  };

  /* ── Palette candy saturée ────────────────────────────────────────────── */
  var PALETTES = {
    candy:   ['#FF1A8C','#FFD700','#00EEFF','#77FF00','#FFFFFF','#FF6600','#CC00FF','#FFE500'],
    gold:    ['#FFD700','#FFF0A0','#F0E0A0','#C8A840','#FFFBE0','#E8C840','#FFE880','#FFF8D0'],
    pastel:  ['#FFB3D9','#FFE4B3','#B3F0FF','#C8FFB3','#F0E8FF','#FFD0C8','#E8C8FF','#FFF8E0'],
    rainbow: ['#FF0000','#FF8800','#FFFF00','#00FF00','#0055FF','#AA00FF','#FF0099','#FFFFFF']
  };
  var COLORS_DEFAULT = PALETTES.candy;

  var MAX_PARTS = 550;

  /* ── Utilitaires ─────────────────────────────────────────────────────── */
  function rnd(a, b) { return a + Math.random() * (b - a); }
  function pick(arr) { return arr[(Math.random() * arr.length) | 0]; }
  function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
  function sgn() { return Math.random() < 0.5 ? 1 : -1; }

  /* ── Couleur sombre (dos) ───────────────────────────────────────────── */
  function darken(hex, f) {
    var r = parseInt(hex.slice(1,3),16);
    var g = parseInt(hex.slice(3,5),16);
    var b = parseInt(hex.slice(5,7),16);
    return 'rgb('+Math.round(r*f)+','+Math.round(g*f)+','+Math.round(b*f)+')';
  }

  /* ── Types de formes ─────────────────────────────────────────────────── */
  var SHAPES = ['rect','rect','rect','rect','circle','ribbon','ribbon','star','star'];

  /* ── Alpha de vie : fondu UNIQUEMENT sur le dernier 25 % ────────────── */
  function lifeAlpha(p) {
    var t  = p.life / p.lifeMax;
    var fi = p.life < 8 ? p.life / 8 : 1;
    var fo = t > 0.75 ? 1 - (t - 0.75) / 0.25 : 1;
    return p.alpha * fi * fo;
  }

  /* ── Instance par scène ──────────────────────────────────────────────── */
  function createInstance(scene) {
    var canvas = scene.querySelector('.cxp-canvas');
    if (!canvas) return null;
    var ctx = canvas.getContext('2d');
    var dpr = window.devicePixelRatio || 1;
    var W = 0, H = 0;
    var raf = null;
    var running = false;
    var lastBurst = 0;
    var AUTO_INTERVAL = 7000;

    /* ── Pool LOCAL ─────────────────────────────────────────────────────── */
    var pool      = [];
    var active    = [];
    var floaters  = [];

    function mkP() {
      return { alive:false,x:0,y:0,vx:0,vy:0,w:0,h:0,
               shape:'rect',color:'#fff',colorDark:'#888',
               rotX:0,rotY:0,rotVX:0,rotVY:0,
               wobPhase:0,wobSpeed:0,
               layer:0,alpha:1,life:0,lifeMax:1,
               seg:null,segLen:0,glintT:0,glintDur:0 };
    }
    function getP() {
      for (var i=0;i<pool.length;i++) { if (!pool[i].alive) return pool[i]; }
      if (pool.length>=MAX_PARTS) return null;
      var p=mkP(); pool.push(p); return p;
    }

    /* ── Configuration d'une particule d'explosion ───────────────────────── */
    function setupP(p, x, y, angle, spd, layerIdx, colors, isFloater) {
      var S  = SPEED[layerIdx];
      var sz = layerIdx===1 ? SZ_NEAR : SZ_FAR;
      p.alive = true; p.x=x; p.y=y;
      p.vx = Math.cos(angle)*spd;
      p.vy = Math.sin(angle)*spd - (isFloater ? 0 : BIAS_UP);
      p.layer = layerIdx; p.alpha = S.alpha;
      p.shape = SHAPES[(Math.random()*SHAPES.length)|0];
      p.color = pick(colors); p.colorDark = darken(p.color,0.30);
      if (p.shape==='rect'||p.shape==='ribbon') {
        p.w=rnd(sz.rw[0],sz.rw[1]); p.h=rnd(sz.rh[0],sz.rh[1]);
      } else if (p.shape==='circle') {
        p.w=rnd(sz.c[0],sz.c[1]); p.h=p.w;
      } else {
        p.w=rnd(sz.s[0],sz.s[1]); p.h=p.w;
      }
      p.rotX=rnd(0,PI2); p.rotY=rnd(0,PI2);
      p.rotVX=rnd(0.04,0.14)*sgn(); p.rotVY=rnd(0.03,0.11)*sgn();
      p.wobPhase=rnd(0,PI2); p.wobSpeed=rnd(0.032,0.090);
      /* Vie 2,4–3,4 s @ 60fps → 144–204 frames ; fondu dernier 25 % */
      p.lifeMax = isFloater ? 99999 : rnd(144,204);
      p.life=0; p.glintT=0; p.glintDur=rnd(10,30);
      if (p.shape==='ribbon') {
        p.segLen=9; p.seg=[];
        for (var s=0;s<p.segLen;s++) p.seg.push({x:p.x,y:p.y});
      } else { p.seg=null; }
    }

    /* ── Émission d'un lot ───────────────────────────────────────────────── */
    function emitBatch(cx, cy, count, colors, power) {
      var emitR = rnd(0,8), emitA = rnd(0,PI2);
      for (var i=0;i<count;i++) {
        var p=getP(); if (!p) break;
        var ea = rnd(0,PI2);
        var ox = cx + Math.cos(ea)*rnd(0,8);
        var oy = cy + Math.sin(ea)*rnd(0,8);
        var angle    = rnd(-PI, PI);
        var layerIdx = Math.random()<0.38 ? 0 : 1;
        var S = SPEED[layerIdx];
        var spd = rnd(S.spMin, S.spMax) * power;
        setupP(p, ox, oy, angle, spd, layerIdx, colors, false);
        active.push(p);
      }
    }

    /* ── Émission étalée sur 4 vagues (120–180 ms) ──────────────────────── */
    var WAVE_DELAYS = [0, 42, 90, 148];   /* ms */
    var WAVE_FRAC   = [0.30, 0.28, 0.25]; /* 3 premières vagues, la 4e = reste */

    function burstStaggered(cx, cy, total, colors, power) {
      if (!running) start();
      colors = colors || getColors();
      power  = power  || (REDUCE ? 0.50 : 1.0);
      WAVE_DELAYS.forEach(function(ms, idx) {
        var cnt;
        if (idx < 3) {
          cnt = Math.floor(total * WAVE_FRAC[idx]);
        } else {
          cnt = total - Math.floor(total*(WAVE_FRAC[0]+WAVE_FRAC[1]+WAVE_FRAC[2]));
        }
        setTimeout(function(){
          emitBatch(cx, cy, cnt, colors, power);
        }, ms);
      });
      lastBurst = performance.now();
    }

    /* ── Confettis flotteurs permanents ─────────────────────────────────── */
    function initFloaters(colors) {
      floaters=[];
      if (REDUCE) return;
      for (var i=0;i<12;i++) {
        var p=mkP();
        var lIdx = Math.random()<0.5 ? 0 : 1;
        var S = SPEED[lIdx];
        p.alive=true; p.x=rnd(0.06,0.94)*W; p.y=rnd(-0.10,0.90)*H;
        p.vx=rnd(-0.3,0.3); p.vy=rnd(0.15,0.50);
        p.layer=lIdx; p.alpha=rnd(0.50,0.80);
        p.shape=SHAPES[(Math.random()*SHAPES.length)|0];
        p.color=pick(colors); p.colorDark=darken(p.color,0.30);
        var sz=lIdx===1?SZ_NEAR:SZ_FAR;
        if (p.shape==='rect'||p.shape==='ribbon') { p.w=rnd(sz.rw[0],sz.rw[1]); p.h=rnd(sz.rh[0],sz.rh[1]); }
        else if (p.shape==='circle') { p.w=rnd(sz.c[0],sz.c[1]); p.h=p.w; }
        else { p.w=rnd(sz.s[0],sz.s[1]); p.h=p.w; }
        p.rotX=rnd(0,PI2); p.rotY=rnd(0,PI2);
        p.rotVX=rnd(0.006,0.022)*sgn(); p.rotVY=rnd(0.005,0.018)*sgn();
        p.wobPhase=rnd(0,PI2); p.wobSpeed=rnd(0.015,0.045);
        p.lifeMax=99999; p.life=30; /* dépasse le fade-in */
        if (p.shape==='ribbon') {
          p.segLen=9; p.seg=[];
          for (var s=0;s<p.segLen;s++) p.seg.push({x:p.x,y:p.y});
        }
        floaters.push(p);
      }
    }

    function updateFloater(p) {
      p.wobPhase+=p.wobSpeed;
      p.vx+=Math.sin(p.wobPhase)*0.018;
      p.vy+=0.014; /* gravité douce */
      p.vx*=0.992; p.vy*=0.992;
      p.x+=p.vx; p.y+=p.vy;
      p.rotX+=p.rotVX; p.rotY+=p.rotVY;
      if (p.shape==='ribbon'&&p.seg) {
        for (var s=p.segLen-1;s>0;s--) { p.seg[s].x=p.seg[s-1].x; p.seg[s].y=p.seg[s-1].y; }
        p.seg[0].x=p.x; p.seg[0].y=p.y;
      }
      if (p.y>H+60) { p.x=rnd(0.06,0.94)*W; p.y=-rnd(15,70); p.vx=rnd(-0.2,0.2); p.vy=rnd(0.15,0.45); }
    }


    /* ── Dessin particules ────────────────────────────────────────────────── */
    function drawRect(p) {
      var cosX=Math.cos(p.rotX),cosY=Math.cos(p.rotY);
      var nz=cosX*cosY;
      var aw=Math.abs(cosY)*p.w,ah=Math.abs(cosX)*p.h;
      if (aw<0.6||ah<0.4) return;
      var a=lifeAlpha(p); if (a<=0.004) return;
      var glint=0;
      if (p.glintT<p.glintDur) { glint=Math.sin(p.glintT/p.glintDur*PI)*0.92; p.glintT++; }
      ctx.save(); ctx.globalAlpha=a;
      ctx.translate(p.x,p.y); ctx.rotate(p.rotX*0.45);
      if (p.layer===1) { ctx.shadowColor=p.color; ctx.shadowBlur=Math.abs(p.vx+p.vy)*0.28; }
      if (glint>0.20)    ctx.fillStyle=glint>0.58?'#ffffff':p.color;
      else if (nz<0)     ctx.fillStyle=p.colorDark;
      else               ctx.fillStyle=p.color;
      ctx.fillRect(-aw/2,-ah/2,aw,ah);
      ctx.shadowBlur=0; ctx.restore();
    }

    function drawCircle(p) {
      var r=Math.abs(Math.cos(p.rotX))*p.w*0.5; if (r<0.4) return;
      var a=lifeAlpha(p); if (a<=0.004) return;
      ctx.save(); ctx.globalAlpha=a;
      ctx.beginPath(); ctx.arc(p.x,p.y,r,0,PI2);
      ctx.fillStyle=Math.cos(p.rotY)<0?p.colorDark:p.color;
      ctx.fill(); ctx.restore();
    }

    function drawRibbon(p) {
      if (!p.seg||p.seg.length<2) return;
      var a=lifeAlpha(p); if (a<=0.004) return;
      ctx.save(); ctx.globalAlpha=a;
      ctx.lineWidth=Math.max(1.2,p.h*0.88);
      ctx.lineCap='round'; ctx.lineJoin='round';
      ctx.strokeStyle=Math.cos(p.rotY)>=0?p.color:p.colorDark;
      ctx.beginPath(); ctx.moveTo(p.seg[0].x,p.seg[0].y);
      for (var s=1;s<p.seg.length;s++) {
        var mx=(p.seg[s-1].x+p.seg[s].x)*0.5,my=(p.seg[s-1].y+p.seg[s].y)*0.5;
        ctx.quadraticCurveTo(p.seg[s-1].x,p.seg[s-1].y,mx,my);
      }
      ctx.stroke(); ctx.restore();
    }

    function drawStar(p) {
      var r=Math.abs(Math.cos(p.rotX))*p.w*0.52; if (r<0.7) return;
      var a=lifeAlpha(p); if (a<=0.004) return;
      ctx.save(); ctx.globalAlpha=a;
      ctx.translate(p.x,p.y); ctx.rotate(p.rotY);
      ctx.beginPath();
      for (var i=0;i<10;i++) {
        var ang=(i*PI)/5-PI/2;
        var rad=(i%2===0)?r:r*0.40;
        if (i===0) ctx.moveTo(Math.cos(ang)*rad,Math.sin(ang)*rad);
        else       ctx.lineTo(Math.cos(ang)*rad,Math.sin(ang)*rad);
      }
      ctx.closePath();
      ctx.fillStyle=Math.cos(p.rotY)>=0?p.color:p.colorDark;
      ctx.fill(); ctx.restore();
    }

    function drawP(p) {
      if (p.shape==='rect')        drawRect(p);
      else if (p.shape==='circle') drawCircle(p);
      else if (p.shape==='ribbon') drawRibbon(p);
      else if (p.shape==='star')   drawStar(p);
    }

    /* ── Physique particule (explosion) ─────────────────────────────────── */
    function updateP(p) {
      var dt=_tickDt;
      p.life+=dt;
      /* Physique Δt-correcte : DRAG^dt compense les fps > 60 */
      var drag=Math.pow(DRAG,dt);
      p.vy+=G*dt; p.vx*=drag; p.vy*=drag;
      p.wobPhase+=p.wobSpeed*dt;
      p.vx+=Math.sin(p.wobPhase)*WOB*(0.20+0.80*Math.random())*0.04*dt;
      p.x+=p.vx*dt; p.y+=p.vy*dt;  /* ×dt : pixels/s indépendants du fps */
      /* Clamp X avec rebond mou (Owen) */
      if (p.x<-10)   { p.x=-10;  if (p.vx<0) p.vx*=-0.35; }
      if (p.x>W+10)  { p.x=W+10; if (p.vx>0) p.vx*=-0.35; }
      var spinDecay=Math.max(0.07,1-p.life/(p.lifeMax*1.8));
      p.rotX+=p.rotVX*spinDecay*dt; p.rotY+=p.rotVY*spinDecay*dt;
      if (p.shape==='ribbon'&&p.seg) {
        for (var s=p.segLen-1;s>0;s--) { p.seg[s].x=p.seg[s-1].x; p.seg[s].y=p.seg[s-1].y; }
        p.seg[0].x=p.x; p.seg[0].y=p.y;
      }
    }

    /* ── Sparkles ambiants ─────────────────────────────────────────────── */
    var sparkles=[];
    function initSparkles(colors) {
      sparkles=[]; if (REDUCE) return;
      for (var i=0;i<14;i++) sparkles.push({
        x:rnd(0.06,0.94),y:rnd(0.06,0.94),r:rnd(0.8,2.0),
        phase:rnd(0,PI2),speed:rnd(0.022,0.058),color:pick(colors),base:rnd(0.07,0.20)
      });
    }
    function drawSparkles(colors) {
      for (var i=0;i<sparkles.length;i++) {
        var sp=sparkles[i]; sp.phase+=sp.speed;
        var a=sp.base*(0.3+0.7*Math.abs(Math.sin(sp.phase)));
        if (Math.random()<0.002) sp.color=pick(colors);
        ctx.save(); ctx.globalAlpha=a; ctx.fillStyle=sp.color;
        ctx.beginPath(); ctx.arc(sp.x*W,sp.y*H,sp.r,0,PI2); ctx.fill(); ctx.restore();
      }
    }

    /* ── Lecture palette ───────────────────────────────────────────────── */
    function getColors() {
      var attr=scene.dataset.colors;
      return attr ? attr.split(',').map(function(s){return s.trim();}) : COLORS_DEFAULT;
    }

    /* ── Redimensionnement ─────────────────────────────────────────────── */
    function resize() {
      var rect=scene.getBoundingClientRect();
      W=rect.width; H=rect.height;
      canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
      canvas.style.width=W+'px'; canvas.style.height=H+'px';
      ctx.scale(dpr,dpr);
    }

    /* ── API burst ─────────────────────────────────────────────────────── */
    function burst(x, y, count, colors, power) {
      if (!running) start();
      burstStaggered(x, y, count||(REDUCE?20:260), colors, power);
    }

    function burstFromBtn(count) {
      var btn=scene.querySelector('.cxp-btn');
      if (btn) {
        var sr=scene.getBoundingClientRect();
        var br=btn.getBoundingClientRect();
        burst(br.left+br.width/2-sr.left, br.top+br.height/2-sr.top, count||260);
      } else { burst(W/2,H/2,count||260); }
    }

    /* ── Boucle de rendu ───────────────────────────────────────────────── */
    function tick(ts) {
      if (!running) return;
      raf=requestAnimationFrame(tick);
      /* Δt normalisé : 60fps→1,0 ; 120fps→0,5 ; cap 3× pour éviter spirale */
      _tickDt = _lastTickTs>0 ? Math.min((ts-_lastTickTs)/16.667, 3.0) : 1.0;
      _lastTickTs = ts;
      /* Auto-explosion toutes les 7 s si scène vide */
      if (!REDUCE&&active.length===0&&ts-lastBurst>=AUTO_INTERVAL) { burstFromBtn(); return; }

      ctx.clearRect(0,0,W,H);
      drawSparkles(getColors());

      /* Flotteurs */
      for (var i=0;i<floaters.length;i++) { updateFloater(floaters[i]); drawP(floaters[i]); }

      /* Particules explosion (lointain d'abord) */
      active.sort(function(a,b){return a.layer-b.layer;});
      for (var j=active.length-1;j>=0;j--) {
        var p=active[j]; updateP(p);
        /* Sortie : sol uniquement (Owen : X clampé, pas de sortie latérale) */
        if (p.life>=p.lifeMax||p.y>H+50) {
          p.alive=false; active.splice(j,1); continue;
        }
        drawP(p);
      }

    }

    function start() {
      if (running) return; running=true; resize();
      var cols=getColors(); initSparkles(cols); initFloaters(cols);
      raf=requestAnimationFrame(tick);
    }

    function stop() {
      running=false;
      if (raf) { cancelAnimationFrame(raf); raf=null; }
      for (var i=0;i<active.length;i++) active[i].alive=false;
      active.length=0;
      if (ctx) ctx.clearRect(0,0,W,H);
    }

    /* ── Événements (hiérarchie clic sécurisée) ──────────────────────── */
    var btn=scene.querySelector('.cxp-btn');

    if (btn) {
      btn.addEventListener('click', function(e) {
        e.stopPropagation();
        burstFromBtn(260);
        if (!REDUCE) {
          btn.classList.add('cxp-punch');
          setTimeout(function(){btn.classList.remove('cxp-punch');},320);
        }
      });
    }

    scene.addEventListener('click', function(e) {
      if (btn&&(e.target===btn||btn.contains(e.target))) return;
      var rect=scene.getBoundingClientRect();
      burst(e.clientX-rect.left, e.clientY-rect.top, 160);
    });

    /* IntersectionObserver */
    if (typeof IntersectionObserver!=='undefined') {
      new IntersectionObserver(function(entries) {
        entries.forEach(function(entry) {
          if (entry.isIntersecting) {
            start();
            if (lastBurst===0) {
              setTimeout(function(){
                if (REDUCE) burstStaggered(W/2,H/2,20,null,0.50);
                else burstFromBtn(260);
              },180);
            }
          } else { stop(); }
        });
      },{threshold:0.15}).observe(scene);
    } else {
      start();
      setTimeout(function(){burstFromBtn(260);},180);
    }

    if (typeof ResizeObserver!=='undefined') { new ResizeObserver(resize).observe(scene); }
    else { window.addEventListener('resize',resize); }

    return { burst:burst, burstFromBtn:burstFromBtn, stop:stop, start:start };
  }

  /* ── Initialisation ───────────────────────────────────────────────────── */
  function init() {
    var scenes=document.querySelectorAll('.cxp-scene');
    var instances=[];
    scenes.forEach(function(scene) {
      var inst=createInstance(scene);
      if (inst) instances.push({scene:scene,inst:inst});
    });
    window.ConfettiExplosion = {
      burst: function(opts) {
        opts=opts||{};
        instances.forEach(function(item) {
          var r=item.scene.getBoundingClientRect();
          item.inst.burst(
            opts.x!=null?opts.x:r.width/2,
            opts.y!=null?opts.y:r.height/2,
            opts.count,opts.colors,opts.power
          );
        });
      }
    };
  }

  if (document.readyState==='loading') { document.addEventListener('DOMContentLoaded',init); }
  else { init(); }

})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
data-colors on .cxp-scene #FF4D9E,#FFD700,#00E5FF,#8AFF59,#F0F4F8,#FF8040,#E040FB,#FDFFB6 Confetti color palette — comma-separated hex values on the HTML element. Override per burst via window.ConfettiExplosion.burst({colors:['#f00','#fff']}).
burst({count}) 260 (20 in reduced-motion) Particles per burst via the API. 60–350 for smooth mobile performance. Clicking outside the button always fires a fixed 160-particle burst.
burst({power}) 1.0 (0.50 in reduced-motion) Initial speed multiplier. 1.0 = normal (near layer spMin 4–8 px/frame @ 60fps), 0.5 = gentle, 1.5 = energetic. Scales both depth layers proportionally.
burst({x, y}) button centre or scene centre Burst origin in CSS pixels within the scene coordinate system. E.g. burst({x: 0, y: sceneH}) to fire from the bottom-left corner.
DRAG = 0.96 0.96 per frame @ 60fps Air drag per frame (Δt-compensated). Raise toward 0.99 for longer-floating confetti; lower toward 0.92 for faster deceleration.
G = 0.06 0.06 px/frame² @ 60fps Gravity per frame (Δt-compensated). Raise to 0.12 for a fast rain-like drop; lower to 0.02 for a micro-gravity floating effect.
AUTO_INTERVAL = 7000 7000 ms Delay in ms between automatic bursts when no click occurs. Set to Infinity to disable repeat and keep only the viewport-entry burst.
PALETTES.candy / gold / pastel / rainbow candy (pink, gold, cyan, lime, white, orange, purple, butter) Four built-in palettes in the IIFE. Change COLORS_DEFAULT = PALETTES.gold at the top of the script to apply a palette to all scenes without data-colors.

FAQ

How do I fire the burst from a button outside the scene?

Call window.ConfettiExplosion.burst({x, y, count, colors, power}) from any event handler. x and y are CSS pixels in the scene's coordinate system; omit them to burst from the center. The API fires all .cxp-scene instances on the page simultaneously.

Does the effect work without a JavaScript framework?

Yes — the code is a vanilla IIFE, zero dependencies. Copy the CSS block and JS block into any HTML page, add a <div class="cxp-scene"><canvas class="cxp-canvas"></canvas></div> and the effect initialises automatically on load.

Does the effect respect prefers-reduced-motion?

Yes. When prefers-reduced-motion is active in the OS, a single short gentle burst (20 particles, power = 0.50) fires on viewport entry, then all animation stops. Permanent floaters and the 7-second auto-repeat are also disabled.