AtmosphèreGratuit

Confetti Explosion

Explosion radiale de confettis métallisés en Canvas 2D : 260 pièces en 4 vagues, éclairage par normale 3D et glints, pluie de 2-3 s, physique normalisée Δt et API ConfettiExplosion.burst().

ConfettiCanvas 2DExplosionCélébration
Survolez ou cliquez la scène pour interagir

Cet effet est gratuit — la catégorie Atmosphère en contient 57 au total, dont 10 gratuits. Effect.Labs regroupe 811 effets vanilla. Découvrir la catégorie →

Exemples d'usage

① Gamification — Badge débloqué

QuandL'utilisateur termine un parcours (formation, défi, streak) et clique sur « Réclamer mon badge » — l'explosion radiale éclate depuis le bouton et inonde le cadre de confettis dorés.
PourquoiLa grande surface sombre laisse les confettis dorés exploser librement.
RéglagesPalette or (#FFD700, #FFF0A0, #FFA500, #FFFFFF, #FDFFB6), count 260, power 1,0. 4 vagues sur 148 ms depuis le bouton centré-bas. DRAG 0,96, G 0,06.

② Victoire — Fin de partie

QuandLa partie se termine et l'écran de résultats s'affiche — tournoi, quiz multijoueur, fin de niveau. L'explosion se déclenche automatiquement à l'entrée dans le viewport (180 ms).
PourquoiL'auto-burst au viewport évite d'exiger un clic sur un écran de résultats — les confettis explosent dès l'affichage et remplissent tout l'espace. Cliquer sur la scène relance un second tir de 160 particules.
RéglagesPalette candy par défaut (#FF4D9E, #FFD700, #00E5FF, #8AFF59, #FF8040, #E040FB), count 260, auto-burst activé. Les 2 couches (alpha 0,70 / 1,00) créent la profondeur.

③ Quiz terminé — Score parfait

QuandL'utilisateur termine un quiz sans faute. La page de résultats révèle le score et le bouton « Partager » déclenche l'explosion depuis le bas de la scène.
PourquoiLe contraste fond sombre + confettis arc-en-ciel rend l'explosion radiale maximalement lisible. Les rubans courbés et les étoiles ajoutent une variété de formes que les interfaces de quiz apprécient.
RéglagesPalette rainbow (#FF0000, #FF8800, #FFFF00, #00FF00, #0055FF, #AA00FF, #FF0099, #FFFFFF), count 260, power 1,0. 9 segments ruban, glint spéculaire, sparkles en idle.

Comment ça marche

L'IIFE instancie une instance par .cxp-scene trouvée dans la page. Chaque instance maintient un pool local ≤ 550 particules, 12 confettis flotteurs permanents qui dérivent lentement vers le bas en boucle, et 14 sparkles qui scintillent entre les explosions. Un seul requestAnimationFrame par instance pilote le rendu ; un IntersectionObserver (seuil 0,15) démarre et arrête la boucle selon la visibilité ; un ResizeObserver recalibre le canvas au redimensionnement en tenant compte du devicePixelRatio.

À chaque tir, les particules partent en 4 vagues étalées sur 148 ms — 30 % à 0 ms, 28 % à 42 ms, 25 % à 90 ms, 17 % à 148 ms — pour éviter un pic de spawn visible. Ensuite la physique prend le relais : traînée d'air 0,96 par frame, gravité 0,06, flottement latéral 0,32 et rotation qui ralentit — les pièces retombent en 2 à 3 s.

Quatre formes se mélangent au hasard : rectangles, ronds, rubans courbés (9 segments en courbe de Bézier quadratique) et étoiles 5 branches. Chaque particule simule une normale 3D via rotX × rotY : face vers l'observateur → couleur principale ; face opposée → teinte assombrie à 30 % (darken(hex, 0.30)). Un glint spéculaire (~92 % d'intensité) décale brièvement la couleur vers le blanc. La physique est Δt-normalisée : traînée DRAG = 0,96 par frame @ 60fps (compensée en DRAG^dt), gravité G = 0,06 px/frame², flottement latéral WOB = 0,32. Les particules vivent 144–204 frames et s'effacent dans le dernier 25 % de leur vie.

En mode repos, les flotteurs dérivent et les sparkles clignotent. Une explosion automatique se déclenche à l'entrée dans le viewport (délai 180 ms) et se répète toutes les 7 s si aucun clic n'a eu lieu. En prefers-reduced-motion, une unique explosion douce (20 particules, power = 0,50) se déclenche à l'entrée puis toute boucle s'arrête — floaters et auto-répétition compris.

Accessibilité

  • prefers-reduced-motion : si réduit, un unique tir doux (20 particules, power = 0,50) se déclenche à l'entrée dans le viewport puis toute animation s'arrête. Les 12 flotteurs permanents et l'auto-répétition toutes les 7 s sont également désactivés.
  • La scène porte role="img" et un aria-label descriptif ; le canvas est marqué aria-hidden="true" — les éléments graphiques n'alimentent pas l'arbre d'accessibilité.
  • Le déclencheur est un <button class="cxp-btn"> HTML natif, focusable au clavier (Tab / Entrée / Espace), avec anneau :focus-visible doré (#fbbf24).
  • Les clics en dehors du bouton déclenchent un tir au point exact du clic (160 particules) — le clavier n'est jamais requis pour accéder à la fonctionnalité principale.

Compatibilité navigateur

Nécessite Canvas 2D, requestAnimationFrame, IntersectionObserver et ResizeObserver — disponibles dans tous les navigateurs modernes. Zéro dépendance externe.

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

Sans support Canvas 2D, la scène reste vide (aucune erreur JS fatale) ; le bouton et tout texte superposé restent lisibles. Sans IntersectionObserver (IE 11), l'effet démarre immédiatement à l'initialisation.

Le code

Copiez les trois blocs dans votre page. Aucune dépendance.

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(); }

})();

Personnaliser

Options passées à l'API ou attributs data-* :

Option / propriétéDéfautEffet
data-colors sur .cxp-scene #FF4D9E,#FFD700,#00E5FF,#8AFF59,#F0F4F8,#FF8040,#E040FB,#FDFFB6 Palette des confettis — liste de hex séparés par des virgules sur l'élément HTML. Surchargeable par tir avec window.ConfettiExplosion.burst({colors:['#f00','#fff']}).
burst({count}) 260 (20 en reduced-motion) Nombre de particules par tir via l'API. Entre 60 et 350 pour des performances fluides sur mobile. Le clic en dehors du bouton génère toujours 160 particules (fixe dans le code).
burst({power}) 1,0 (0,50 en reduced-motion) Multiplicateur de vitesse initiale. 1,0 = normal (couche proche : spMin 4–8 px/frame @ 60fps), 0,5 = doux, 1,5 = énergique. Modifie les deux couches proportionnellement.
burst({x, y}) centre du bouton ou centre scène Origine du tir en pixels CSS dans le référentiel de la scène. Ex. : burst({x: 0, y: sceneH}) pour tirer depuis le bas-gauche.
DRAG = 0,96 0,96 par frame @ 60fps Traînée d'air par frame (compensée en Δt). Augmenter vers 0,99 pour des confettis qui planent plus longtemps ; diminuer vers 0,92 pour un freinage plus rapide.
G = 0,06 0,06 px/frame² @ 60fps Gravité par frame (compensée en Δt). Augmenter à 0,12 pour une retombée rapide type pluie ; diminuer à 0,02 pour un effet flottant microgravité.
AUTO_INTERVAL = 7000 7000 ms Délai en ms entre deux explosions auto-lancées si aucun clic n'a eu lieu. Mettre Infinity pour désactiver la répétition et ne conserver que le burst à l'entrée viewport.
PALETTES.candy / gold / pastel / rainbow candy (rose, or, cyan, citron, blanc, orange, violet, beurre) Quatre palettes prédéfinies dans l'IIFE. Remplacer COLORS_DEFAULT = PALETTES.gold en tête du script pour changer la palette de toutes les scènes sans data-colors.

FAQ

Comment déclencher l'explosion depuis un bouton externe à la scène ?

Appelez window.ConfettiExplosion.burst({x, y, count, colors, power}) depuis n'importe quel événement. x et y sont en pixels CSS dans le référentiel de la scène ; sans ces valeurs, l'explosion part du centre. L'API déclenche toutes les instances .cxp-scene de la page simultanément.

L'effet fonctionne-t-il sans JavaScript framework ?

Oui — le code est une IIFE vanilla, zéro dépendance. Copiez le bloc CSS et le bloc JS dans n'importe quelle page HTML, ajoutez un <div class="cxp-scene"><canvas class="cxp-canvas"></canvas></div> et l'effet s'initialise automatiquement au chargement.

L'effet respecte-t-il prefers-reduced-motion ?

Oui. Quand prefers-reduced-motion est activé dans l'OS, une seule explosion courte et douce (20 particules, power = 0,50) se déclenche à l'entrée dans le viewport, puis toute animation s'arrête. Les flotteurs permanents et l'auto-répétition toutes les 7 secondes sont également désactivés.