AtmosphèreGratuit

Confetti Cannon

Deux canons aux coins inférieurs propulsent des confettis en papier métallisé — rectangles avec éclairage par normale 3D, rubans torsadés bicolores, étoiles, cercles et 3 couches de parallaxe. Tir au clic, API window.ConfettiCannon.

ConfettiCanvas 2DPhysiqueSerpentinsCé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

① Écran de victoire — Classement final plein écran

QuandQuand la partie se termine et que l'écran de résultats s'affiche — tournoi, quiz multijoueur, jeu en ligne, fin de niveau.
PourquoiLes canons tirent depuis les deux coins bas et inondent toute la surface de l'écran. Le fond sombre et le podium laissent les confettis exploser librement sans rien masquer d'essentiel.
RéglagesPalette festive (or, violet, rose), count 140, fireCannons déclenché à l'affichage de l'écran de résultats ou au clic « Rejouer ».

② Composant produit — Confirmation de commande

QuandAu moment où la page de récapitulatif de commande s'affiche après paiement validé — confirmation e-commerce, réservation, inscription payante.
PourquoiLe confetti renforce la satisfaction post-achat et réduit l'anxiété post-conversion. Il déclenche au clic du CTA, jamais automatiquement.
RéglagesPalette premium par défaut (corail, or, turquoise, violet), count 100–140. Le fond peut rester transparent par-dessus la vraie page de confirmation.

③ Micro-interaction de conversion — Validation d'abonnement

QuandÀ l'appui sur le CTA de souscription (SaaS, newsletter payante, membership) avant que la page ne redirige vers le tunnel de paiement.
PourquoiLe micro-confetti vert renforce la couleur « Go » du bouton et donne un retour visuel immédiat avant la redirection, limitant l'impression de latence perçue.
RéglagesPalette verte monochrome (shades d'emerald), count 80, vitesse 0,9×. Tir unique depuis le centre bas du bouton : fire({x: btnCX, y: btnBY, count:80}).

Comment ça marche

Un seul requestAnimationFrame pilote toutes les scènes de la page. Chaque instance Scene maintient un tableau de particules actives (≤ 450) et un pool de bruine ambiante (≤ 55 particules légères qui dérivent en continu).

À chaque tir, deux canons s'ouvrent depuis les coins bas avec un éclair de bouche (dégradé radial en mode composite:lighter) et 7 étincelles courtes par canon. Les confettis partent en 5 vagues étalées sur 150 ms (60 % immédiat, 40 % différé) pour éviter un pic de spawn.

Les rectangles et carrés calculent leur éclairage par normale 3D : rotX + rotY → vecteur normal de surface → produit scalaire avec la source lumineuse (haut-gauche-avant) → composante diffuse ± 50 % de luminosité + glint spéculaire blanc quand diff > 0,60. Les rubans torsadés (serpentins) varient leur largeur apparente par segment (|cos(twist)|) pour simuler la torsion. La parallaxe répartit les particules sur 3 couches (lointain · milieu · proche) avec des échelles et des vitesses différentes ; la couche proche reçoit un léger motion-blur (fantôme semi-transparent décalé de 2,2 px dans la direction du vecteur vitesse).

Le DPR (devicePixelRatio) est pris en compte au redimensionnement : le canvas est mis à l'échelle physique, le contexte est transformé, les particules restent en coordonnées CSS. Un ResizeObserver recalibre automatiquement quand le conteneur change de taille.

Accessibilité

  • prefers-reduced-motion : si réduit, un unique tir court (20 particules, 0,7× vitesse) se déclenche à l'initialisation puis l'animation s'arrête — aucune boucle perpétuelle.
  • La scène porte role="img" et aria-label descriptif ; le canvas est marqué aria-hidden="true".
  • Le bouton « Célébrer » est un <button> natif, focusable au clavier, avec :focus-visible en doré (#fbbf24).
  • Le bouton Pause respecte WCAG 2.2.2 (mécanisme de pause pour toute animation auto-lancée de plus de 5 secondes).
  • La bruine ambiante est désactivée via prefers-reduced-motion: reduce (la fonction drizzle() retourne immédiatement).

Compatibilité navigateur

Nécessite Canvas 2D et requestAnimationFrame — supportés dans tous les navigateurs modernes. Zéro dépendance externe.

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

Sans support Canvas, la scène reste vide (aucune erreur JS fatale) ; le CTA et le texte restent lisibles.

Le code

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

HTML
<div class="ccn-scene" id="ccnScene"
           role="img"
           aria-label="Confetti Cannon premium — deux canons avec éclairage 3D, parallaxe et étincelles"
           data-colors="#FF6B6B,#FFD166,#06D6A0,#9B5DE5,#F0F4F8,#FF8E53,#00BBF9,#F15BB5,#FEE440,#FDFFB6">
        <canvas class="ccn-canvas" aria-hidden="true"></canvas>
        <div class="ccn-label" aria-hidden="true">Clic · ou</div>
        <button class="ccn-celebrate-btn" aria-label="Lancer les canons à confettis">🎉 Célébrer</button>
      </div>
CSS
/* -- Scène (conteneur) -- */
.ccn-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 -- */
.ccn-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  display: block;
}

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

/* -- Bouton Célébrer (dans la démo) -- */
.ccn-celebrate-btn {
  position: absolute;
  bottom: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 2;
  background: rgba(255, 255, 255, 0.10);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: #f1f5f9;
  padding: 8px 22px;
  border-radius: 24px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.14s, transform 0.10s;
  outline: 2px solid transparent;
  letter-spacing: 0.01em;
}
.ccn-celebrate-btn:hover  { background: rgba(255,255,255,0.18); }
.ccn-celebrate-btn:active { transform: translateX(-50%) scale(0.96); }
.ccn-celebrate-btn:focus-visible { outline-color: #fbbf24; outline-offset: 2px; }

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

/* -- Exemple d'usage : carte commande confirmée -- */
.ccn-order-scene {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: 14px;
}

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

.ccn-order-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;
}

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

.ccn-order-btn {
  background: linear-gradient(135deg,#f97316,#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;
}
.ccn-order-btn:hover  { opacity:0.88; }
.ccn-order-btn:active { transform:scale(0.96); }
.ccn-order-btn:focus-visible { outline-color:#fbbf24; outline-offset:2px; }

/* -- prefers-reduced-motion -- */
@media (prefers-reduced-motion: reduce) {
  .ccn-celebrate-btn, .ccn-order-btn { transition:none; }
  .ccn-celebrate-btn:active { transform:translateX(-50%); }
  .ccn-order-btn:active     { transform:none; }
}

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

/* -- Responsive mini -- */
@media (max-width:480px) {
  .ccn-order-card   { padding:16px 18px; min-width:180px; }
  .ccn-celebrate-btn { font-size:12px; padding:7px 16px; }
}
JavaScript (fx-0836)
(function () {
  'use strict';

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

  /* ── Physique ──────────────────────────────────────────────────────────── */
  var G    = 0.17;   // gravité (légèrement moins pour flottement premium)
  var DRAG = 0.976;  // traînée aérienne
  var WOB  = 0.42;   // amplitude flottement feuille morte

  /* ── Vecteur lumière normalisé (haut-gauche-avant) ─────────────────────── */
  var LX = 0.32, LY = -0.58, LZ = 0.75;

  /* ── 3 couches de profondeur ─────────────────────────────────────────────
     scale    : facteur taille particule
     vs       : facteur vitesse initiale
     alpha    : opacité de base
     blur     : coefficient de motion-blur (0 = aucun)                       */
  var LAYERS = [
    { scale:0.44, vs:0.54, alpha:0.50, blur:0    },   /* 0 = lointain  */
    { scale:0.82, vs:0.84, alpha:0.84, blur:0    },   /* 1 = milieu    */
    { scale:1.28, vs:1.32, alpha:1.00, blur:0.30 }    /* 2 = proche    */
  ];

  /* ── Palette premium par défaut ──────────────────────────────────────────
     Corail · Or · Turquoise · Violet · Blanc nacré · Orange · Cyan · Rose · Jaune · Crème */
  var COLORS_DEFAULT = ['#FF6B6B','#FFD166','#06D6A0','#9B5DE5','#F0F4F8','#FF8E53','#00BBF9','#F15BB5','#FEE440','#FDFFB6'];

  var MAX_PARTS = 450;

  /* ── 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 de particule) ───────────────────────────────────*/
  function hexDark(hex) {
    var r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
    return 'rgb('+Math.max(0,r-60)+','+Math.max(0,g-60)+','+Math.max(0,b-60)+')';
  }

  /* ── Applique éclairage : diff ∈ [-1,1] → luminosité +/- 50% ─────────── */
  function applyLight(hex, diff) {
    var r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
    var f = 1 + clamp(diff,-1,1)*0.50;
    return 'rgb('+Math.round(clamp(r*f,0,255))+','+Math.round(clamp(g*f,0,255))+','+Math.round(clamp(b*f,0,255))+')';
  }

  /* ── Fabricants de particules ─────────────────────────────────────────── */

  /* Rectangle avec éclairage 3D (rotX + rotY) */
  function mkRect(x,y,vx,vy,col,layer) {
    var L=LAYERS[layer];
    return { t:'r', x:x,y:y, vx:vx*L.vs,vy:vy*L.vs,
      w:rnd(10,20)*L.scale, h:rnd(4,9)*L.scale,
      rotY:rnd(0,PI2), rotX:rnd(0,PI2),
      spinY:rnd(0.05,0.14)*sgn(), spinX:rnd(0.03,0.08)*sgn(),
      wph:rnd(0,PI2), wfr:rnd(0.04,0.09),
      col:col, back:hexDark(col),
      layer:layer, alpha:L.alpha, life:1, decay:rnd(0.0011,0.0032) };
  }

  /* Carré (rect carré) */
  function mkSquare(x,y,vx,vy,col,layer) {
    var L=LAYERS[layer], s=rnd(5,13)*L.scale;
    return { t:'r', x:x,y:y, vx:vx*L.vs,vy:vy*L.vs,
      w:s, h:s,
      rotY:rnd(0,PI2), rotX:rnd(0,PI2),
      spinY:rnd(0.05,0.16)*sgn(), spinX:rnd(0.03,0.09)*sgn(),
      wph:rnd(0,PI2), wfr:rnd(0.04,0.09),
      col:col, back:hexDark(col),
      layer:layer, alpha:L.alpha, life:1, decay:rnd(0.0012,0.0034) };
  }

  /* Cercle */
  function mkCircle(x,y,vx,vy,col,layer) {
    var L=LAYERS[layer];
    return { t:'c', x:x,y:y, vx:vx*L.vs,vy:vy*L.vs,
      r:rnd(3,6.5)*L.scale,
      wph:rnd(0,PI2), wfr:rnd(0.05,0.10),
      col:col, layer:layer, alpha:L.alpha, life:1, decay:rnd(0.0018,0.0045) };
  }

  /* Étoile 5 branches */
  function mkStar(x,y,vx,vy,col,layer) {
    var L=LAYERS[layer];
    return { t:'star', x:x,y:y, vx:vx*L.vs,vy:vy*L.vs,
      r:rnd(5,9)*L.scale,
      theta:rnd(0,PI2), spin:rnd(0.06,0.18)*sgn(),
      wph:rnd(0,PI2), wfr:rnd(0.04,0.09),
      col:col, layer:layer, alpha:L.alpha, life:1, decay:rnd(0.0015,0.0038) };
  }

  /* Ruban torsadé (serpentin) */
  function mkSerpentin(x,y,vx,vy,col,layer) {
    var L=LAYERS[layer], n=(rnd(7,13)|0), seg=[];
    for(var i=0;i<n;i++) seg.push({x:x,y:y});
    return { t:'s', x:x,y:y, vx:vx*L.vs,vy:vy*L.vs,
      segs:seg, n:n,
      slen:rnd(5,12)*L.scale,
      ph:rnd(0,PI2), wfr:rnd(0.06,0.12),
      lw:rnd(2.5,5)*L.scale,
      col:col, back:hexDark(col),
      twspd:rnd(0.05,0.14),    /* vitesse de torsion */
      tck:0,
      layer:layer, alpha:L.alpha, life:1, decay:rnd(0.0011,0.0030) };
  }

  /* Étincelle de bouche (vie très courte, bright) */
  function mkSpark(x,y,mainAng) {
    var a=mainAng+(Math.random()-0.5)*1.4, v=rnd(9,20);
    return { t:'spark', x:x,y:y, vx:Math.cos(a)*v,vy:Math.sin(a)*v,
      r:rnd(1.5,3.5), col:'#FFF9C4', life:1, decay:rnd(0.05,0.12), layer:2, alpha:1 };
  }

  /* Bruine ambiante – mkAmb* : particules recyclées au passage en bas */
  function mkAmbRect(W,col) {
    return { t:'r', amb:true, alpha:rnd(0.40,0.62),
      x:rnd(0,W),y:rnd(-30,-5), vx:rnd(-0.7,0.7),vy:rnd(1.1,2.6),
      w:rnd(5,12),h:rnd(3,7),
      rotY:rnd(0,PI2),rotX:rnd(0,PI2), spinY:rnd(0.02,0.06)*sgn(),spinX:rnd(0.01,0.04)*sgn(),
      wph:rnd(0,PI2),wfr:rnd(0.03,0.07), col:col,back:hexDark(col), layer:1, life:1,decay:0 };
  }
  function mkAmbCircle(W,col) {
    return { t:'c', amb:true, alpha:rnd(0.40,0.62),
      x:rnd(0,W),y:rnd(-30,-5), vx:rnd(-0.4,0.4),vy:rnd(1.1,2.6),
      r:rnd(1.5,4), wph:rnd(0,PI2),wfr:rnd(0.04,0.08), col:col, layer:1, life:1,decay:0 };
  }
  function mkAmbStar(W,col) {
    return { t:'star', amb:true, alpha:rnd(0.40,0.62),
      x:rnd(0,W),y:rnd(-30,-5), vx:rnd(-0.3,0.3),vy:rnd(0.9,2.0),
      r:rnd(2.5,4.5), theta:rnd(0,PI2),spin:rnd(0.01,0.04)*sgn(),
      wph:rnd(0,PI2),wfr:rnd(0.03,0.07), col:col, layer:1, life:1,decay:0 };
  }
  function mkAmbSerpentin(W,col) {
    var n=(rnd(3,6)|0),sx=rnd(0,W),sy=rnd(-30,-5),seg=[];
    for(var i=0;i<n;i++) seg.push({x:sx,y:sy});
    return { t:'s', amb:true, alpha:rnd(0.40,0.62),
      x:sx,y:sy, vx:rnd(-0.4,0.4),vy:rnd(0.9,2.0),
      segs:seg,n:n, slen:rnd(4,8),ph:rnd(0,PI2),wfr:rnd(0.04,0.09),
      lw:rnd(1.5,3),col:col,back:hexDark(col), twspd:rnd(0.03,0.08), tck:0,
      layer:1, life:1,decay:0 };
  }

  /* ── Reset particules ambiantes ──────────────────────────────────────────*/
  function resetAmb(p,W) {
    p.x=rnd(0,W); p.y=rnd(-30,-5); p.vx=rnd(-0.7,0.7); p.vy=rnd(1.1,2.6); p.alpha=rnd(0.40,0.62);
  }
  function resetAmbC(p,W) {
    p.x=rnd(0,W); p.y=rnd(-30,-5); p.vx=rnd(-0.4,0.4); p.vy=rnd(1.1,2.6); p.alpha=rnd(0.40,0.62);
  }
  function resetAmbStar(p,W) {
    p.x=rnd(0,W); p.y=rnd(-30,-5); p.vx=rnd(-0.3,0.3); p.vy=rnd(0.9,2.0); p.alpha=rnd(0.40,0.62);
  }
  function resetAmbSerpentin(p,W) {
    p.x=rnd(0,W); p.y=rnd(-30,-5);
    for(var k=0;k<p.n;k++){p.segs[k].x=p.x;p.segs[k].y=p.y;}
    p.vx=rnd(-0.4,0.4); p.vy=rnd(0.9,2.0); p.alpha=rnd(0.25,0.48);
  }

  /* ── Update ──────────────────────────────────────────────────────────────*/
  function upRect(p,dt) {
    var dd=Math.pow(DRAG,dt);
    p.vy=(p.vy+G*dt)*dd; p.vx*=dd;
    p.wph+=p.wfr*dt; p.vx+=Math.sin(p.wph)*WOB*0.10*dt;
    p.x+=p.vx*dt; p.y+=p.vy*dt;
    p.rotY+=p.spinY*dt; p.rotX+=p.spinX*dt;
    p.life-=p.decay*dt;
  }
  function upCircle(p,dt) {
    var dd=Math.pow(DRAG,dt);
    p.vy=(p.vy+G*dt)*dd; p.vx*=dd;
    p.wph+=p.wfr*dt; p.vx+=Math.sin(p.wph)*WOB*0.08*dt;
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.life-=p.decay*dt;
  }
  function upStar(p,dt) {
    var dd=Math.pow(DRAG,dt);
    p.vy=(p.vy+G*dt)*dd; p.vx*=dd;
    p.wph+=p.wfr*dt; p.vx+=Math.sin(p.wph)*WOB*0.09*dt;
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.theta+=p.spin*dt; p.life-=p.decay*dt;
  }
  function upSerpentin(p,dt) {
    var dd=Math.pow(DRAG,dt);
    p.vy=(p.vy+G*0.60*dt)*dd; p.vx*=dd;
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.tck+=dt;
    p.segs[0].x=p.x; p.segs[0].y=p.y;
    for(var i=1;i<p.n;i++) {
      var dx=p.segs[i-1].x-p.segs[i].x, dy=p.segs[i-1].y-p.segs[i].y;
      var d=Math.sqrt(dx*dx+dy*dy);
      if(d>p.slen){ var pull=(d-p.slen)/d*0.80; p.segs[i].x+=dx*pull; p.segs[i].y+=dy*pull; }
      p.segs[i].x+=Math.sin(p.tck*p.wfr+i*0.75+p.ph)*0.80*dt;
    }
    p.life-=p.decay*dt;
  }
  function upSpark(p,dt) {
    p.vy+=G*1.8*dt; p.vx*=0.90; p.vy*=0.90;
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.life-=p.decay*dt;
  }

  /* ── Draw ─────────────────────────────────────────────────────────────── */

  /* Rectangle 3D : éclairage normal + glint spéculaire + motion-blur couche proche */
  function drRect(ctx,p,lAlpha) {
    var cosY=Math.cos(p.rotY), cosX=Math.cos(p.rotX);
    var sinY=Math.sin(p.rotY), sinX=Math.sin(p.rotX);
    var visW=p.w*Math.abs(cosY), visH=p.h*Math.abs(cosX);
    if(visW<0.4||visH<0.3) return;

    /* Normale de surface & éclairage diffus */
    var nx=sinY, ny=-sinX*cosY, nz=Math.abs(cosX*cosY);
    var diff=nx*LX+ny*LY+nz*LZ;                      /* ∈ [-1, 1] */

    /* Face visible (cosY ≥ 0 = face avant) */
    var faceCol=cosY>=0 ? p.col : p.back;
    var litCol=applyLight(faceCol, diff);

    /* Glint spéculaire : reflet blanc bref quand diff est élevé */
    var glint=0;
    if(diff>0.60) glint=Math.pow((diff-0.60)/0.40,1.8)*0.95;

    var finalA=(p.amb?p.alpha:Math.max(0,p.life))*lAlpha;
    if(finalA<0.005) return;

    var tilt=Math.atan2(p.vy,p.vx)*0.25;

    /* Motion-blur pour le layer proche — alpha fixe 0.22, indépendant de dt */
    if(p.layer===2&&!p.amb&&LAYERS[2].blur>0) {
      ctx.save();
      ctx.globalAlpha=0.22;
      ctx.fillStyle=litCol;
      ctx.translate(p.x-p.vx*2.2, p.y-p.vy*2.2);
      ctx.rotate(tilt);
      ctx.fillRect(-visW/2,-visH/2,visW,visH);
      ctx.restore();
    }

    ctx.save();
    ctx.globalAlpha=finalA;
    ctx.fillStyle=litCol;
    ctx.translate(p.x,p.y);
    ctx.rotate(tilt);
    ctx.fillRect(-visW/2,-visH/2,visW,visH);

    /* Overlay glint — durée 1.10 pour effet métallisé perceptible */
    if(glint>0.04) {
      ctx.globalAlpha=finalA*glint*1.10;
      ctx.fillStyle='#FFFFFF';
      ctx.fillRect(-visW/2,-visH/2,visW,visH);
    }
    ctx.restore();
  }

  /* Cercle */
  function drCircle(ctx,p,lAlpha) {
    var finalA=(p.amb?p.alpha:Math.max(0,p.life))*lAlpha;
    if(finalA<0.005) return;
    ctx.save(); ctx.globalAlpha=finalA; ctx.fillStyle=p.col;
    ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,PI2); ctx.fill(); ctx.restore();
  }

  /* Étoile 5 branches */
  function drStar(ctx,p,lAlpha) {
    var finalA=(p.amb?p.alpha:Math.max(0,p.life))*lAlpha;
    if(finalA<0.005) return;
    var R=p.r, r=p.r*0.42;
    ctx.save(); ctx.globalAlpha=finalA; ctx.fillStyle=p.col;
    ctx.translate(p.x,p.y); ctx.rotate(p.theta);
    ctx.beginPath();
    for(var i=0;i<10;i++){
      var a=(i*PI/5)-PI/2, rad=(i&1)?r:R;
      if(i===0) ctx.moveTo(Math.cos(a)*rad,Math.sin(a)*rad);
      else       ctx.lineTo(Math.cos(a)*rad,Math.sin(a)*rad);
    }
    ctx.closePath(); ctx.fill(); ctx.restore();
  }

  /* Ruban torsadé : largeur varie par segment (torsion visible) */
  function drSerpentin(ctx,p,lAlpha) {
    if(p.n<2) return;
    var finalA=(p.amb?p.alpha:Math.max(0,p.life))*lAlpha;
    if(finalA<0.005) return;
    ctx.save(); ctx.lineCap='round';
    for(var i=1;i<p.n;i++){
      var twist=Math.cos(p.tck*p.twspd+i*0.85+p.ph);
      var lw=p.lw*(0.35+0.65*Math.abs(twist));
      /* largeur plancher : le ruban torsadé ne se hache jamais */
      /* Face vs dos selon signe de la torsion */
      var fc=twist>=0 ? p.col : p.back;
      /* Éclairage simplifié basé sur angle de torsion */
      var diff2=twist*LZ+0.28;
      var litCol2=applyLight(fc, clamp(diff2,-0.5,0.82));
      ctx.globalAlpha=finalA;
      ctx.strokeStyle=litCol2;
      ctx.lineWidth=lw;
      ctx.beginPath();
      ctx.moveTo(p.segs[i-1].x,p.segs[i-1].y);
      ctx.lineTo(p.segs[i].x,p.segs[i].y);
      /* Ombre légère (critère 1) */
      ctx.save(); ctx.globalAlpha=finalA*0.18; ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.translate(0.8,1.5); ctx.stroke(); ctx.restore();
      ctx.stroke();
    }
    ctx.restore();
  }

  /* Étincelle avec halo */
  function drSpark(ctx,p) {
    var a=Math.max(0,p.life);
    if(a<0.005) return;
    ctx.save();
    ctx.globalAlpha=a;
    ctx.fillStyle='#FFFDE7';
    ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,PI2); ctx.fill();
    /* Halo lumineux */
    var g=ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,p.r*3.5);
    g.addColorStop(0,'rgba(255,253,200,'+(a*0.55)+')');
    g.addColorStop(1,'rgba(255,253,200,0)');
    ctx.globalAlpha=1;
    ctx.fillStyle=g;
    ctx.beginPath(); ctx.arc(p.x,p.y,p.r*3.5,0,PI2); ctx.fill();
    ctx.restore();
  }

  /* Éclair de bouche : glow radial en composite:lighter */
  function drFlash(ctx,fl) {
    var prog=fl.t/fl.maxT;
    var alpha=(1-prog)*0.92, radius=16+prog*68;
    var g=ctx.createRadialGradient(fl.x,fl.y,0,fl.x,fl.y,radius);
    g.addColorStop(0,  'rgba(255,248,190,'+alpha+')');
    g.addColorStop(0.3,'rgba(255,210,80,'+(alpha*0.62)+')');
    g.addColorStop(0.7,'rgba(255,130,30,'+(alpha*0.28)+')');
    g.addColorStop(1,  'rgba(255,100,20,0)');
    ctx.save();
    ctx.globalCompositeOperation='lighter';
    ctx.fillStyle=g;
    ctx.beginPath(); ctx.arc(fl.x,fl.y,radius,0,PI2); ctx.fill();
    ctx.restore();
  }

  /* ── Sélecteur de couche (35 % lointain, 40 % milieu, 25 % proche) ───── */
  function pickLayer() {
    var r=Math.random(); return r<0.35?0:r<0.75?1:2;
  }

  /* ── Scène ───────────────────────────────────────────────────────────────*/
  function Scene(el) {
    this.el      = el;
    this.parts   = [];
    this.flashes = [];    /* éclairs de bouche */
    this.queue   = [];    /* émission différée : {x,y,vx,vy,col,delay} */
    this.qEl     = 0;     /* ms écoulées dans la queue courante */
    this.running = false;
    this.raf     = null;
    this.W = 480; this.H = 300;
    this._lastTs = null;
    this._ambAcc = 0;

    this.cv  = el.querySelector('.ccn-canvas');
    if(!this.cv){
      this.cv=document.createElement('canvas');
      this.cv.className='ccn-canvas';
      this.cv.setAttribute('aria-hidden','true');
      el.insertBefore(this.cv,el.firstChild);
    }
    this.ctx = this.cv.getContext('2d');

    var attr=el.getAttribute('data-colors');
    this.colors=attr?attr.split(',').map(function(c){return c.trim();}):COLORS_DEFAULT.slice();

    this.resize();

    var self=this;
    if(window.ResizeObserver) new ResizeObserver(function(){self.resize();}).observe(el);

    el.addEventListener('click',function(e){
      if(REDUCE) return;
      if(e.target&&e.target.classList&&e.target.classList.contains('ccn-celebrate-btn')) return;
      self.fireCannons({});
    });

    var btn=el.querySelector('.ccn-celebrate-btn');
    if(btn) btn.addEventListener('click',function(e){e.stopPropagation();if(!REDUCE)self.fireCannons({});});

    if(window.IntersectionObserver){
      var lastAuto=0;
      new IntersectionObserver(function(entries){
        if(!entries[0].isIntersecting){self.stopLoop();return;}
        if(!self.running) self.startLoop();
        var now=Date.now();
        if(!REDUCE&&now-lastAuto>6000){lastAuto=now;self.fireCannons({});}
      },{threshold:0.35}).observe(el);
    }
  }

  Scene.prototype.resize=function(){
    var dpr=window.devicePixelRatio||1;
    var w=this.el.offsetWidth||480, h=this.el.offsetHeight||300;
    var cw=Math.round(w*dpr), ch=Math.round(h*dpr);
    if(this.cv.width!==cw||this.cv.height!==ch){
      this.cv.width=cw; this.cv.height=ch;
      this.cv.style.width=w+'px'; this.cv.style.height=h+'px';
      this.ctx.setTransform(dpr,0,0,dpr,0,0);
    }
    this.W=w; this.H=h;
  };

  Scene.prototype.spawnOne=function(x,y,vx,vy,col,layer){
    if(this.parts.length>=MAX_PARTS) return;
    var r=Math.random();
    if     (r<0.32) this.parts.push(mkRect(x,y,vx,vy,col,layer));
    else if(r<0.44) this.parts.push(mkSquare(x,y,vx,vy,col,layer));
    else if(r<0.60) this.parts.push(mkSerpentin(x,y,vx,vy,col,layer));
    else if(r<0.73) this.parts.push(mkCircle(x,y,vx,vy,col,layer));
    else            this.parts.push(mkStar(x,y,vx,vy,col,layer));
  };

  /* fire() : un tir depuis (x,y) — émission étalée + flash + étincelles */
  Scene.prototype.fire=function(opts){
    opts=opts||{};
    var x  =opts.x     !==undefined?opts.x     :this.W/2;
    var y  =opts.y     !==undefined?opts.y     :this.H;
    var ang=opts.angle !==undefined?opts.angle :-PI/2;
    var spd=opts.spread!==undefined?opts.spread:PI/3;
    var cnt=opts.count ||60;
    var col=opts.colors||this.colors;
    var spf=opts.speed ||1;
    var self=this;

    /* Recul du canon (critère 3) : 5 frames de décalage visuel */
    this._recoilFrames = 5;
    this._recoilDx = Math.cos(ang) * 3;
    this._recoilDy = Math.sin(ang) * 3;

    /* Éclair de bouche */
    this.flashes.push({x:x,y:y,t:0,maxT:210});

    /* Étincelles de bouche */
    if(!REDUCE){
      for(var s=0;s<7;s++){
        if(this.parts.length<MAX_PARTS) this.parts.push(mkSpark(x,y,ang));
      }
    }

    /* Émission : 60% immédiate + 40% étalée en 5 vagues (30ms chacune) */
    var immediate=Math.ceil(cnt*0.60);
    var deferred =cnt-immediate;
    var waveSz   =Math.max(1,Math.ceil(deferred/5));

    for(var i=0;i<immediate;i++){
      var a=ang+(Math.random()-0.5)*spd;
      var v=rnd(7,14)*spf;
      self.spawnOne(x,y,Math.cos(a)*v,Math.sin(a)*v,pick(col),pickLayer());
    }

    for(var w=0;w<5;w++){
      for(var j=0;j<waveSz;j++){
        if(this.parts.length+this.queue.length>=MAX_PARTS) break;
        var a2=ang+(Math.random()-0.5)*spd;
        var v2=rnd(7,14)*spf;
        this.queue.push({x:x,y:y,vx:Math.cos(a2)*v2,vy:Math.sin(a2)*v2,col:col,delay:(w+1)*30});
      }
    }
    this.qEl=0;

    if(!this.running) this.startLoop();
    this.step(1);   /* synchro canvas avant capture éventuelle */
  };

  Scene.prototype.fireCannons=function(opts){
    opts=opts||{};
    var col=opts.colors||this.colors;
    var cnt=opts.count ||140;
    var spd=Math.max(1,this.H/270);
    this.fire({x:0,      y:this.H,angle:-1.13,spread:PI/3,count:(cnt/2)|0,colors:col,speed:spd});
    this.fire({x:this.W, y:this.H,angle:-2.01,spread:PI/3,count:(cnt/2)|0,colors:col,speed:spd});
  };

  /* Traitement de la queue différée */
  Scene.prototype.processQueue=function(dtMs){
    if(!this.queue.length) return;
    this.qEl+=dtMs;
    var remaining=[];
    for(var i=0;i<this.queue.length;i++){
      var it=this.queue[i];
      if(it.delay<=this.qEl) this.spawnOne(it.x,it.y,it.vx,it.vy,pick(it.col),pickLayer());
      else remaining.push(it);
    }
    this.queue=remaining;
  };

  Scene.prototype.startLoop=function(){
    if(this.running) return;
    this.running=true; this._lastTs=null;
    var lbl=this.el.querySelector('.ccn-label');
    if(lbl) lbl.style.opacity='0';
    var self=this;
    function tick(ts){
      if(!self.running) return;
      var dt=self._lastTs?Math.min((ts-self._lastTs)/16.67,3):1;
      self._lastTs=ts;
      self.drizzle(dt);
      self.processQueue(dt*16.67);
      self.step(dt);
      /* Restaurer le label quand l'écran est vide (correction juge) */
      if(!self.parts.length && !self.queue.length && lbl) lbl.style.opacity='1';
      if(self.parts.length||self.flashes.length||self.queue.length||!REDUCE)
        self.raf=requestAnimationFrame(tick);
      else {
        self.running=false;
        self.ctx.clearRect(0,0,self.W,self.H);
        var l=self.el.querySelector('.ccn-label');
        if(l) l.style.opacity='1';
      }
    }
    this.raf=requestAnimationFrame(tick);
  };

  Scene.prototype.stopLoop=function(){
    this.running=false; this._ambAcc=0;
    if(this.raf){cancelAnimationFrame(this.raf);this.raf=null;}
  };

  /* Mise à jour + rendu d'un frame */
  Scene.prototype.step=function(dt){
    dt=dt||1;
    var ctx=this.ctx, W=this.W, H=this.H;
    ctx.clearRect(0,0,W,H);

    /* Recul du canon : décalage du repère de rendu sur 5 frames */
    var hasRecoil=(this._recoilFrames||0)>0;
    if(hasRecoil){
      ctx.save();
      ctx.translate(this._recoilDx||0, this._recoilDy||0);
      this._recoilFrames--;
    }

    /* 1. Éclairs de bouche (fond) */
    var aliveF=[];
    for(var f=0;f<this.flashes.length;f++){
      var fl=this.flashes[f];
      drFlash(ctx,fl); fl.t+=dt*16.67;
      if(fl.t<fl.maxT) aliveF.push(fl);
    }
    this.flashes=aliveF;

    /* 2. Update toutes les particules → alive */
    var alive=[];
    for(var i=0;i<this.parts.length;i++){
      var p=this.parts[i], dead=false;
      if(p.t==='r'||p.t==='sq'){
        upRect(p,dt);
        if(p.amb){ if(p.y>H+10) resetAmb(p,W); }
        else dead=p.life<=0.01||p.y>H+70;
      } else if(p.t==='s'){
        upSerpentin(p,dt);
        if(p.amb){ if(p.segs[0].y>H+10) resetAmbSerpentin(p,W); }
        else dead=p.life<=0.01||p.segs[0].y>H+90;
      } else if(p.t==='c'){
        upCircle(p,dt);
        if(p.amb){ if(p.y>H+10) resetAmbC(p,W); }
        else dead=p.life<=0.01||p.y>H+60;
      } else if(p.t==='star'){
        upStar(p,dt);
        if(p.amb){ if(p.y>H+10) resetAmbStar(p,W); }
        else dead=p.life<=0.01||p.y>H+60;
      } else if(p.t==='spark'){
        upSpark(p,dt); dead=p.life<=0.01||p.y>H+30;
      }
      if(!dead) alive.push(p);
    }
    this.parts=alive;

    /* 3. Rendu en 3 passes (far→mid→near) pour la parallaxe */
    for(var pass=0;pass<3;pass++){
      for(var i=0;i<this.parts.length;i++){
        var p=this.parts[i];
        var rl=p.amb?1:p.layer;
        /* Étincelles et sparks toujours au pass 2 */
        if(p.t==='spark'){ if(pass===2) drSpark(ctx,p); continue; }
        if(rl!==pass) continue;
        var la=p.amb?1:LAYERS[p.layer].alpha;
        if(p.t==='r')    drRect(ctx,p,la);
        else if(p.t==='s')    drSerpentin(ctx,p,la);
        else if(p.t==='c')    drCircle(ctx,p,la);
        else if(p.t==='star') drStar(ctx,p,la);
      }
    }

    if(hasRecoil) ctx.restore();
  };

  /* Bruine ambiante : 1 particule toutes ~230ms, max 55 ambiantes */
  Scene.prototype.drizzle=function(dt){
    if(REDUCE) return;
    this._ambAcc=(this._ambAcc||0)+dt;
    if(this._ambAcc<14) return;
    this._ambAcc-=14;
    var ambN=0;
    for(var i=0;i<this.parts.length;i++) if(this.parts[i].amb) ambN++;
    if(ambN>=55||this.parts.length>=MAX_PARTS) return;
    var r=Math.random(), c=pick(this.colors);
    if     (r<0.40) this.parts.push(mkAmbRect(this.W,c));
    else if(r<0.65) this.parts.push(mkAmbCircle(this.W,c));
    else if(r<0.85) this.parts.push(mkAmbStar(this.W,c));
    else            this.parts.push(mkAmbSerpentin(this.W,c));
  };

  /* ── Init ────────────────────────────────────────────────────────────────*/
  var _scenes=[];
  function init(){
    document.querySelectorAll('.ccn-scene').forEach(function(el){
      var s=new Scene(el);
      _scenes.push(s);
      if(REDUCE){
        s.fire({x:s.W/2,y:s.H,angle:-PI/2,spread:PI*0.55,count:20,speed:0.70});
      } else {
        s.fireCannons({});
      }
    });
  }
  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init);
  else init();

  /* ── API publique ────────────────────────────────────────────────────────*/
  window.ConfettiCannon={
    fire:        function(opts){ _scenes.forEach(function(s){s.fire(opts||{});});},
    fireCannons: function(opts){ _scenes.forEach(function(s){s.fireCannons(opts||{});});},
    fireScene:   function(idx,opts){ if(_scenes[idx]) _scenes[idx].fireCannons(opts||{});},
    scenes:      _scenes
  };

}());

Personnaliser

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

Option / propriétéDéfautEffet
data-colors 10 couleurs premium Palette de confettis sur le .ccn-scene — liste de hex séparés par des virgules.
opts.count 140 Nombre total de confettis par appel à fireCannons(). Entre 40 et 250 pour des performances fluides.
opts.colors scene.colors Tableau de couleurs hex pour ce tir spécifique — surcharge data-colors.
opts.x / opts.y centre / bas Position en pixels CSS du point de départ pour fire(). Utile pour tirer depuis un bouton précis.
opts.angle -π/2 (vertical) Angle de tir en radians. -1,13 = coin gauche, -2,01 = coin droit (défaut fireCannons).
opts.spread π/3 (60°) Ouverture du cône de dispersion en radians.
opts.speed auto (H/270) Multiplicateur de vitesse initiale. 1 = normal, 0,7 = doux, 1,4 = énergique.
scene.colors Propriété directe : ConfettiCannon.scenes[0].colors = ['#f00','#0f0'] — change la palette pour les prochains tirs.
.ccn-scene.ccn-hc absent Classe CSS à ajouter pour activer le mode contraste fort (filter: contrast(1.45) brightness(1.12)).

FAQ

Confetti Cannon est-il vraiment gratuit ?

Oui — cet effet est disponible sans compte. Le CSS, le HTML et le JS complets sont en accès libre sur cette page. La bibliothèque Effect.Labs contient des dizaines d'effets dans la catégorie Atmosphère, dont plusieurs gratuits et de nombreux premium.

Puis-je changer les couleurs des confettis ?

Oui, via l'attribut data-colors sur le .ccn-scene (liste de hex) ou en passant un tableau colors à fireCannons(). Vous pouvez aussi modifier window.ConfettiCannon.scenes[0].colors à la volée pour le prochain tir.

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

Oui. Quand prefers-reduced-motion est réduit, le script déclenche un unique tir court et doux (20 particules, vitesse 0,7×) puis s'arrête : aucune animation en boucle. La bruine ambiante continue est également désactivée.