Comment créer des effets de particules sur un site web ?

Les effets de particules remplissent une scène de nombreux petits éléments en mouvement : étoiles, neige, pluie, fumée, feu, confettis. Ils sont dessinés dans un élément canvas HTML, où une boucle JavaScript pilotée par requestAnimationFrame met à jour la position, la vitesse et la durée de vie de chaque particule, puis redessine l'image. Des centaines de particules restent ainsi fluides, là où des éléments du DOM ralentiraient.

Comment choisir, performances et accessibilité
  • Utilisez Starfield ou Snow Fall pour un fond d'ambiance, Confetti Explosion pour célébrer une inscription ou un achat, et Attraction Field quand les particules doivent réagir au pointeur.
  • Adaptez le nombre de particules à la taille de l'écran, arrêtez la boucle quand le canvas sort de l'écran ou que l'onglet est masqué, et affichez une image fixe pour prefers-reduced-motion.
  • La page propose aussi des sons d'interface, comme Click Sound Button et Notification Chime, qui ne se déclenchent qu'après une action de l'utilisateur.
  • Starfield, Confetti Explosion et plusieurs effets sonores sont gratuits ; les autres sont inclus dans Premium.
Générateur

Ces particules, réglez-les vous-même

Les effets ci-dessous se copient tels quels. Le générateur de particules va plus loin : vous réglez les formes, les couleurs et l'enchaînement, vous lui donnez votre logo ou votre texte, et le résultat se voit en direct — puis vous emportez le code, en un fichier autonome.

Ce n'est pas une image : le moteur tourne pour de vrai, et il réagit au curseur.

Ouvrir le générateur

Starfield

Champ d'étoiles en mouvement vers le spectateur avec effet de profondeur et traînées lumineuses.

JS Canvas Stars
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview">
  <div class="ps-canvas-wrap">
    <canvas class="ps-canvas" id="psStarfield"></canvas>
  </div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 280px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 15);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.demo-preview:has(.ps-canvas-wrap) {
  min-height: 280px;
  background: #0a0a0f;
}

.ps-canvas {
  width: 100%;
  height: 280px;
  display: block;
  border-radius: 0;
}

.ps-canvas-wrap {
  position: relative;
  width: 100%;
  height: 280px;
}

.ps-canvas-wrap canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}
JavaScript
(function() {
  /* fx-0485 — Starfield (IIFE scopée) — restauré le 11/08/2026 : le code de cet effet
     gratuit (data-free) avait été retiré par erreur avec le code premium (anti-scalping #13). */
  (function() {
    const canvas = document.getElementById('psStarfield');
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const rect = canvas.parentElement.getBoundingClientRect();
    canvas.width = rect.width;
    canvas.height = rect.height;
    const w = canvas.width,
      h = canvas.height;
    const stars = [];
    for (let i = 0; i < 200; i++) {
      stars.push({
        x: Math.random() * w - w / 2,
        y: Math.random() * h - h / 2,
        z: Math.random() * w
      });
    }

    function draw() {
      ctx.fillStyle = 'rgba(10,10,15,0.2)';
      ctx.fillRect(0, 0, w, h);
      stars.forEach(s => {
        s.z -= 2;
        if (s.z <= 0) {
          s.z = w;
          s.x = Math.random() * w - w / 2;
          s.y = Math.random() * h - h / 2;
        }
        const sx = (s.x / s.z) * w / 2 + w / 2;
        const sy = (s.y / s.z) * h / 2 + h / 2;
        const r = Math.max(0.5, (1 - s.z / w) * 2.5);
        const a = 1 - s.z / w;
        ctx.beginPath();
        ctx.arc(sx, sy, r, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(255,255,255,${a})`;
        ctx.fill();
      });
      requestAnimationFrame(draw);
    }
    draw();
  })();
})();

Snow Fall

Flocons de neige tombant avec tailles variées, oscillation horizontale et opacité aléatoire.

JS Canvas Snow

Fire Effect

Particules de feu montantes avec dégradé de couleur (jaune→orange→rouge) et dissipation progressive.

JS Canvas Fire

Smoke

Fumée s'élevant avec expansion progressive, dérive latérale douce et disparition en transparence.

JS Canvas Smoke

Attraction Field

Particules attirées vers le curseur avec force proportionnelle à la distance. Liens entre particules proches.

JS Canvas Interactive

Explosion

Cliquez pour déclencher une explosion de particules colorées avec gravité, friction et dissipation.

JS Canvas Click

Fireflies

Lucioles flottantes avec pulsation lumineuse individuelle, mouvement erratique et halo de lumière.

JS Canvas Glow

Rain

Gouttes de pluie tombant en diagonale avec splash à l'impact et accumulation au sol.

JS Canvas Weather

Galaxy Spiral

Galaxie spirale avec bras en rotation, étoiles de couleurs variées et noyau lumineux central.

JS Canvas Galaxy

Trail Effect

Traînée de particules colorées suivant le curseur avec rotation, réduction et disparition progressive.

JS Canvas Mouse

Ambient Light Glow

Élément émettant un halo lumineux ambiant coloré via box-shadow multi-couches avec spread progressif.

CSS Animation Glow

Day/Night Cycle

Transition fluide à travers un cycle complet jour/nuit avec dégradés animés et soleil en mouvement.

CSS Animation Gradient

Fog Layer

Brouillard semi-transparent animé dérivant à travers le contenu via pseudo-éléments et translateX.

CSS Pseudo Animation

Fireflies

Lucioles flottant aléatoirement avec lueur douce pulsante sur fond sombre, purement CSS.

CSS Keyframes Particles

Northern Lights

Aurore boréale animée avec dégradés multi-couches, mouvement fluide et rotation de teinte.

CSS Gradient Hue-rotate

Dust Motes

Particules de poussière flottant dans un rayon de lumière avec animations aléatoires subtiles.

CSS Animation Particles

Heat Haze

Distorsion subtile ondulante simulant l'air chaud via filtre SVG feTurbulence animé.

SVG Filter Animation

Underwater Caustics

Motifs lumineux animés comme sous la surface de l'eau avec dégradés coniques et bulles montantes.

CSS Gradient Animation

Snow Fall

Chute de neige réaliste avec flocons de tailles et vitesses variées, dérive légère du vent, pur CSS.

CSS Keyframes Particles

Light Leak

Fuites de lumière et flares animés style film avec dégradés et mix-blend-mode screen.

CSS Blend Mode Animation

Morphing Blob

Forme organique qui se déforme en continu.

CSS Organic

Ripple Effect

Onde qui se propage depuis le point de clic.

JS Material

Particle Explosion

Particules qui explosent dans toutes les directions.

JS Particles

Liquid Button

Effet liquide qui remplit le bouton.

CSS Hover

Matrix Rain

Effet pluie de code style Matrix.

JS Retro

Fireworks

Cliquez pour lancer des feux d'artifice.

JS Celebration

Confetti Explosion

Une explosion radiale de confettis en papier métallisé jaillit depuis le bouton central ou depuis n'importe quel point cliqué dans la scène. Chaque particule — rectangle, rond, ruban courbé ou étoile — possède une normale 3D simulée qui génère un éclairage diffuse, une tranche sombre et un glint spéculaire bref, avec deux tons face/dos. Deux couches de profondeur créent la sensation d'espace. En mode prefers-reduced-motion, une unique explosion courte et douce se déclenche puis cesse.

💡 Idéal pour : célébration d'objectif atteint, milestone utilisateur, écran de félicitations, badge débloqué, activation d'abonnement.

Canvas 2D Normale 3D Explosion radiale
Voir le code source
HTML
<div class="demo-preview">

  <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">Cliquez n'importe où · ou</div>
    <button class="cxp-btn" aria-label="Lancer une explosion de confettis">🎉 Celebrate!</button>
  </div>

</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

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

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

.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;

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

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

.cxp-btn.cxp-punch {
  animation: cxp-btn-punch 0.30s cubic-bezier(0.36, 0.07, 0.19, 0.97) forwards;
}

@media (prefers-reduced-motion: reduce) {
  .cxp-btn {
    animation: none;
    transition: none;
  }

  .cxp-btn.cxp-punch {
    animation: none;
  }

  .cxp-btn:active {
    transform: translate(-50%, 50%);
  }
}

@media (max-width: 480px) {
  .cxp-btn {
    font-size: 13px;
    padding: 8px 18px;
  }
}

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

@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);
  }
}
JavaScript
(function() {
  /* 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();
    }

  })();
})();
Page de l’effet →

Snow Fall

Flocons de neige qui tombent doucement.

JS Winter

Starfield

Effet de voyage dans l'espace.

JS Space

Lightning

Éclairs aléatoires avec effet de flash.

JS Storm

Frequency Bars

Barres audio classiques qui réagissent au rythme.

JS Classic
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview">
  <div class="frequency-bars" id="frequencyBars"></div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.frequency-bars {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 4px;
  height: 120px;
}

.frequency-bars .bar {
  width: 8px;
  background: linear-gradient(to top, #6366f1, #d946ef);
  border-radius: 4px 4px 0 0;
  transition: height 0.05s ease;
}
JavaScript
(function() {
  /* fx-0515 — Frequency Bars (gratuit, autonome — restauré 22/08/2026) */
  (function() {
    const frequencyBars = document.getElementById('frequencyBars');
    if (!frequencyBars) return;
    const barCount = 20;
    for (let i = 0; i < barCount; i++) {
      const bar = document.createElement('div');
      bar.className = 'bar';
      bar.style.height = '10px';
      frequencyBars.appendChild(bar);
    }
    // Audio simulé (pas de micro ni de fichier) : superposition de sinus + bruit
    function getSimulatedAudio(count, time, baseFreq = 1) {
      const values = [];
      for (let i = 0; i < count; i++) {
        const freq = baseFreq + i * 0.5;
        const value = Math.abs(
          Math.sin(time * freq * 0.002) * 0.5 +
          Math.sin(time * freq * 0.003 + i) * 0.3 +
          Math.random() * 0.2
        );
        values.push(Math.min(1, value));
      }
      return values;
    }
    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    function animate(time) {
      const bars = frequencyBars.querySelectorAll('.bar');
      const data = getSimulatedAudio(barCount, time);
      bars.forEach((bar, i) => {
        bar.style.height = (10 + data[i] * 100) + 'px';
      });
      if (!reduceMotion) requestAnimationFrame(animate);
    }
    requestAnimationFrame(animate);
  })();
})();

Circular Visualizer

Barres disposées en cercle pour un effet radial.

JS Radial

Wave Form

Onde sinusoïdale animée en continu.

SVG Smooth

Pulse Ring

Anneaux qui pulsent vers l'extérieur au rythme.

JS Beat

Particle Burst

Particules qui explosent sur les beats.

JS Explosive

Spectrum Gradient

Barres avec gradient de couleurs dynamique.

JS Colorful

3D Bars

Barres avec perspective 3D et ombres.

CSS 3D Depth

Blob Morph

Blob organique qui se déforme au son.

SVG Organic

Line Graph

Ligne continue animée avec remplissage.

SVG Continuous

Neon Glow Bars

Barres avec effet neon lumineux.

CSS Glow

Effets Sonores

Click Sound Button

Bouton avec feedback sonore génère par Web Audio API au clic.

Web Audio Bouton Feedback
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview" style="display: flex; align-items: center; justify-content: center;">
  <button class="snd-click-btn" id="sndClickBtn">
    <span class="snd-wave">&#128266;</span> Click Me
  </button>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.snd-click-btn {
  padding: 14px 28px;
  border: none;
  border-radius: 10px;
  background: linear-gradient(135deg, #6366f1, #a855f7);
  color: #fff;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.15s, box-shadow 0.15s;
  display: flex;
  align-items: center;
  gap: 8px;
}

.snd-click-btn:active {
  transform: scale(0.95);
}

.snd-click-btn:hover {
  box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
}

.snd-click-btn .snd-wave {
  display: inline-block;
  transition: transform 0.2s;
}
JavaScript
(function() {
  (function() {
    var btn = document.getElementById('sndClickBtn');
    if (!btn) return;
    var ctx = null;
    // Le contexte audio ne peut naitre que sur un geste : on le cree au 1er clic.
    function audio() {
      return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
    }
    btn.addEventListener('click', function() {
      var a = audio(),
        t = a.currentTime;
      var o = a.createOscillator(),
        g = a.createGain();
      o.type = 'triangle';
      o.frequency.setValueAtTime(880, t);
      o.frequency.exponentialRampToValueAtTime(320, t + 0.05);
      g.gain.setValueAtTime(0.14, t);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 0.09);
      o.connect(g).connect(a.destination);
      o.start(t);
      o.stop(t + 0.1);
    });
  })();
})();
🎵
Survolez-moi

Hover Sound Card

Carte qui émet un son subtil au survol grâce a Web Audio API.

Web Audio Hover Carte
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview" style="display: flex; align-items: center; justify-content: center;">
  <div class="snd-hover-card" id="sndHoverCard">
    <div class="snd-hover-icon">&#127925;</div>
    <div class="snd-hover-label">Survolez-moi</div>
  </div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

/* Variables du design system du site, dont ce code dépend (valeurs de css/style.css — à adapter librement). */
.demo-preview {
  --text-secondary: #a1a1aa;
}

.snd-hover-card {
  width: 180px;
  padding: 20px;
  border-radius: 12px;
  background: rgba(99, 102, 241, 0.1);
  border: 1px solid rgba(99, 102, 241, 0.2);
  text-align: center;
  transition: all 0.3s ease;
  cursor: pointer;
}

.snd-hover-card:hover {
  transform: translateY(-4px);
  border-color: #6366f1;
  background: rgba(99, 102, 241, 0.2);
}

.snd-hover-icon {
  font-size: 2rem;
  margin-bottom: 8px;
}

.snd-hover-label {
  font-size: 0.8rem;
  color: var(--text-secondary);
}
JavaScript
(function() {
  (function() {
    var carte = document.getElementById('sndHoverCard');
    if (!carte) return;
    var ctx = null,
      dernier = 0;

    function audio() {
      return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
    }
    carte.addEventListener('mouseenter', function() {
      if (Date.now() - dernier < 250) return; // pas de mitraillage au survol
      dernier = Date.now();
      var a = audio(),
        t = a.currentTime;
      var o = a.createOscillator(),
        g = a.createGain();
      o.type = 'sine';
      o.frequency.setValueAtTime(523.25, t); // do5
      o.frequency.linearRampToValueAtTime(659.25, t + 0.12);
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(0.1, t + 0.02);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
      o.connect(g).connect(a.destination);
      o.start(t);
      o.stop(t + 0.24);
    });
  })();
})();

Typing Sound Input

Champ de texte avec son de frappe clavier génère synthétiquement à chaque touche.

Web Audio Input Keyboard
✅ Nouveau message recu !

Notification Chime

Notification avec son de cloche synthétisé et animation d'apparition.

Web Audio Notification Chime
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview" style="display: flex; align-items: center; justify-content: center;">
  <div class="snd-notif-container">
    <button class="snd-notif-trigger" id="sndNotifBtn">&#128276; Notification</button>
    <div class="snd-notif-toast" id="sndNotifToast">&#9989; Nouveau message recu !</div>
  </div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.snd-notif-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

.snd-notif-trigger {
  padding: 10px 20px;
  border-radius: 8px;
  background: rgba(16, 185, 129, 0.2);
  border: 1px solid rgba(16, 185, 129, 0.3);
  color: #10b981;
  cursor: pointer;
  font-size: 0.85rem;
}

.snd-notif-toast {
  padding: 10px 16px;
  border-radius: 8px;
  background: rgba(16, 185, 129, 0.15);
  border: 1px solid rgba(16, 185, 129, 0.3);
  color: #10b981;
  font-size: 0.8rem;
  opacity: 0;
  transform: translateY(10px);
  transition: all 0.3s ease;
}

.snd-notif-toast.show {
  opacity: 1;
  transform: translateY(0);
}
JavaScript
(function() {
  (function() {
    var btn = document.getElementById('sndNotifBtn'),
      toast = document.getElementById('sndNotifToast');
    if (!btn || !toast) return;
    var ctx = null,
      minuteur = null;

    function audio() {
      return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
    }

    function note(a, freq, depart, duree, vol) {
      var o = a.createOscillator(),
        g = a.createGain();
      o.type = 'sine';
      o.frequency.setValueAtTime(freq, depart);
      g.gain.setValueAtTime(0.0001, depart);
      g.gain.exponentialRampToValueAtTime(vol, depart + 0.015);
      g.gain.exponentialRampToValueAtTime(0.0001, depart + duree);
      o.connect(g).connect(a.destination);
      o.start(depart);
      o.stop(depart + duree + 0.02);
    }
    btn.addEventListener('click', function() {
      var a = audio(),
        t = a.currentTime;
      note(a, 880, t, 0.18, 0.12); // la5
      note(a, 1318.5, t + 0.1, 0.35, 0.1); // mi6 — carillon a deux notes
      toast.classList.add('show');
      clearTimeout(minuteur);
      minuteur = setTimeout(function() {
        toast.classList.remove('show');
      }, 2600);
    });
  })();
})();

Slider Tick Sound

Slider avec ticks sonores à chaque changement de valeur par pas de 10.

Web Audio Slider Tick
OFF

Toggle Switch Sound

Switch on/off avec sons différenciés pour chaque état.

Web Audio Toggle UI
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview" style="display: flex; align-items: center; justify-content: center;">
  <div class="snd-toggle-container">
    <div class="snd-toggle" id="sndToggle">
      <div class="snd-toggle-knob"></div>
    </div>
    <div class="snd-toggle-label" id="sndToggleLabel">OFF</div>
  </div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

/* Variables du design system du site, dont ce code dépend (valeurs de css/style.css — à adapter librement). */
.demo-preview {
  --text-secondary: #a1a1aa;
}

.snd-toggle-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.snd-toggle {
  width: 60px;
  height: 32px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.1);
  position: relative;
  cursor: pointer;
  transition: background 0.3s;
}

.snd-toggle.on {
  background: #6366f1;
}

.snd-toggle-knob {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: #fff;
  position: absolute;
  top: 3px;
  left: 3px;
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.snd-toggle.on .snd-toggle-knob {
  transform: translateX(28px);
}

.snd-toggle-label {
  font-size: 0.8rem;
  color: var(--text-secondary);
}
JavaScript
(function() {
  (function() {
    var inter = document.getElementById('sndToggle'),
      label = document.getElementById('sndToggleLabel');
    if (!inter) return;
    var ctx = null;

    function audio() {
      return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
    }
    inter.addEventListener('click', function() {
      var allume = inter.classList.toggle('on');
      if (label) label.textContent = allume ? 'ON' : 'OFF';
      var a = audio(),
        t = a.currentTime;
      var o = a.createOscillator(),
        g = a.createGain();
      o.type = 'square';
      // montee a l'allumage, descente a l'extinction : le son dit l'etat
      o.frequency.setValueAtTime(allume ? 420 : 760, t);
      o.frequency.exponentialRampToValueAtTime(allume ? 760 : 420, t + 0.07);
      g.gain.setValueAtTime(0.07, t);
      g.gain.exponentialRampToValueAtTime(0.0001, t + 0.11);
      o.connect(g).connect(a.destination);
      o.start(t);
      o.stop(t + 0.12);
    });
  })();
})();

Scroll Ambient Sound

Son d'ambiance dont la fréquence varie en fonction de la position de scroll.

Web Audio Scroll Ambient

Drag Feedback Sound

Son de friction continu pendant le drag, variant avec la vitesse de déplacement.

Web Audio Drag Friction

Success/Error Tone

Sons de validation/erreur synthétisés avec des accords majeurs et mineurs.

Web Audio Feedback UX
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview" style="display: flex; align-items: center; justify-content: center;">
  <div class="snd-tones">
    <button class="snd-tone-btn snd-tone-success" id="sndSuccess">&#9989; Succes</button>
    <button class="snd-tone-btn snd-tone-error" id="sndError">&#10060; Erreur</button>
  </div>
</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.snd-tones {
  display: flex;
  gap: 16px;
}

.snd-tone-btn {
  padding: 12px 24px;
  border-radius: 8px;
  border: none;
  cursor: pointer;
  font-size: 0.85rem;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 6px;
  transition: transform 0.2s;
}

.snd-tone-btn:active {
  transform: scale(0.95);
}

.snd-tone-success {
  background: rgba(16, 185, 129, 0.2);
  color: #10b981;
  border: 1px solid rgba(16, 185, 129, 0.3);
}

.snd-tone-error {
  background: rgba(239, 68, 68, 0.2);
  color: #ef4444;
  border: 1px solid rgba(239, 68, 68, 0.3);
}
JavaScript
(function() {
  (function() {
    var ok = document.getElementById('sndSuccess'),
      ko = document.getElementById('sndError');
    if (!ok && !ko) return;
    var ctx = null;

    function audio() {
      return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
    }

    function jouer(freqs, type, pas, duree, vol) {
      var a = audio(),
        t = a.currentTime;
      freqs.forEach(function(f, i) {
        var o = a.createOscillator(),
          g = a.createGain(),
          d = t + i * pas;
        o.type = type;
        o.frequency.setValueAtTime(f, d);
        g.gain.setValueAtTime(0.0001, d);
        g.gain.exponentialRampToValueAtTime(vol, d + 0.015);
        g.gain.exponentialRampToValueAtTime(0.0001, d + duree);
        o.connect(g).connect(a.destination);
        o.start(d);
        o.stop(d + duree + 0.02);
      });
    }
    // arpege majeur qui monte = reussite ; deux notes basses qui descendent = erreur
    if (ok) ok.addEventListener('click', function() {
      jouer([523.25, 659.25, 783.99], 'sine', 0.09, 0.22, 0.1);
    });
    if (ko) ko.addEventListener('click', function() {
      jouer([311.13, 233.08], 'sawtooth', 0.13, 0.3, 0.07);
    });
  })();
})();

Ambient Soundscape

Paysage sonore interactif : activez/désactivez des couches de sons d'ambiance.

Web Audio Ambient Interactif

Liquid Metal Shader

Surface métallique liquide en WebGL. Raymarching simplifié, ondulations Perlin, réflexion lumière dynamique.

WebGL Shader Gratuit
Page de l’effet →
Voir le code source
HTML
<div class="ps-canvas-wrap">
  <canvas class="ps-canvas" id="psLiquidMetal"></canvas>
</div>
CSS
.demo-preview:has(.ps-canvas-wrap)  {
   min-height: 280px;
   background: rgb(10, 10, 15);
   }

.ps-canvas  {
   width: 100%;
   height: 280px;
   display: block;
   border-radius: 0px;
   }

.ps-canvas-wrap  {
   position: relative;
   width: 100%;
   height: 280px;
   }

.ps-canvas-wrap canvas  {
   position: absolute;
   top: 0px;
   left: 0px;
   width: 100%;
   height: 100%;
   }
JavaScript
(function() {
  var settings = { color: 'silver', speed: 1.0, distortion: 0.6, lightAngle: 45 };
  var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  function initMetal(canvas) {
  // Reduced motion : fallback CSS gradient déjà en place via @media
  if (reduceMotion) return;

  const gl = canvas.getContext('webgl', { antialias: false, alpha: false }) ||
             canvas.getContext('experimental-webgl');

  // WebGL not supported → CSS gradient fallback (handled in CSS @media query)
  if (!gl) {
    canvas.style.display = 'none';
    return;
  }

  // === Vertex shader : fullscreen quad ===
  const VS = `
    attribute vec2 a_pos;
    void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
  `;

  // === Fragment shader : liquid metal via Perlin noise + raymarching simplifié ===
  const FS = `
    precision highp float;
    uniform vec2  u_res;
    uniform float u_time;
    uniform float u_speed;
    uniform float u_distortion;
    uniform float u_light_angle;
    uniform vec3  u_color_a;
    uniform vec3  u_color_b;
    uniform vec3  u_color_c;

    // Classic 2D simplex noise (Ashima Arts, MIT)
    vec3 mod289(vec3 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
    vec2 mod289(vec2 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
    vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }

    float snoise(vec2 v){
      const vec4 C = vec4(0.211324865405187, 0.366025403784439,
                         -0.577350269189626, 0.024390243902439);
      vec2 i  = floor(v + dot(v, C.yy));
      vec2 x0 = v - i + dot(i, C.xx);
      vec2 i1 = (x0.x > x0.y) ? vec2(1.0,0.0) : vec2(0.0,1.0);
      vec4 x12 = x0.xyxy + C.xxzz;
      x12.xy -= i1;
      i = mod289(i);
      vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0))
                              + i.x + vec3(0.0, i1.x, 1.0));
      vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
      m = m*m; m = m*m;
      vec3 x = 2.0 * fract(p * C.www) - 1.0;
      vec3 h = abs(x) - 0.5;
      vec3 ox = floor(x + 0.5);
      vec3 a0 = x - ox;
      m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
      vec3 g;
      g.x  = a0.x  * x0.x  + h.x  * x0.y;
      g.yz = a0.yz * x12.xz + h.yz * x12.yw;
      return 130.0 * dot(m, g);
    }

    // Octave fbm for richer surface
    float fbm(vec2 p) {
      float v = 0.0;
      float a = 0.5;
      for (int i = 0; i < 4; i++) {
        v += a * snoise(p);
        p *= 2.0;
        a *= 0.5;
      }
      return v;
    }

    void main() {
      vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / min(u_res.x, u_res.y);
      float t = u_time * u_speed * 0.3;

      // Surface height via fbm
      float h = fbm(uv * 2.0 + vec2(t, t * 0.7)) * u_distortion;

      // Surface normal via gradient (finite differences)
      float eps = 0.01;
      float hx = fbm((uv + vec2(eps,0.0)) * 2.0 + vec2(t, t*0.7)) * u_distortion;
      float hy = fbm((uv + vec2(0.0,eps)) * 2.0 + vec2(t, t*0.7)) * u_distortion;
      vec3 normal = normalize(vec3((h - hx)/eps, (h - hy)/eps, 1.0));

      // Light direction
      float angle = radians(u_light_angle);
      vec3 lightDir = normalize(vec3(cos(angle), sin(angle), 0.6));

      // Specular metal reflection
      float diffuse = max(dot(normal, lightDir), 0.0);
      vec3 viewDir = vec3(0.0, 0.0, 1.0);
      vec3 reflectDir = reflect(-lightDir, normal);
      float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);

      // Iridescence factor (used by gold/iridescent presets)
      float irid = 0.5 + 0.5 * sin(h * 8.0 + t * 2.0);

      // Mix three colour stops based on diffuse + iridescence
      vec3 base = mix(u_color_a, u_color_b, diffuse);
      base = mix(base, u_color_c, irid * 0.5);

      // Metal gloss : add specular highlight
      vec3 finalColor = base + vec3(spec * 1.2);

      // Vignette subtle
      float vig = 1.0 - 0.4 * length(uv);
      finalColor *= vig;

      gl_FragColor = vec4(finalColor, 1.0);
    }
  `;

  // === Compile + link program ===
  function compile(type, src) {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      console.error('Shader compile error:', gl.getShaderInfoLog(sh));
      gl.deleteShader(sh);
      return null;
    }
    return sh;
  }

  const vs = compile(gl.VERTEX_SHADER, VS);
  const fs = compile(gl.FRAGMENT_SHADER, FS);
  if (!vs || !fs) return;

  const prog = gl.createProgram();
  gl.attachShader(prog, vs);
  gl.attachShader(prog, fs);
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
    console.error('Program link error:', gl.getProgramInfoLog(prog));
    return;
  }
  gl.useProgram(prog);

  // === Fullscreen quad ===
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
  const aPos = gl.getAttribLocation(prog, 'a_pos');
  gl.enableVertexAttribArray(aPos);
  gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

  // === Uniforms ===
  const uRes = gl.getUniformLocation(prog, 'u_res');
  const uTime = gl.getUniformLocation(prog, 'u_time');
  const uSpeed = gl.getUniformLocation(prog, 'u_speed');
  const uDist = gl.getUniformLocation(prog, 'u_distortion');
  const uLight = gl.getUniformLocation(prog, 'u_light_angle');
  const uColA = gl.getUniformLocation(prog, 'u_color_a');
  const uColB = gl.getUniformLocation(prog, 'u_color_b');
  const uColC = gl.getUniformLocation(prog, 'u_color_c');

  // === Color presets ===
  const PALETTES = {
    silver:     { a: [0.10, 0.11, 0.13], b: [0.85, 0.88, 0.92], c: [0.55, 0.60, 0.70] },
    gold:       { a: [0.20, 0.13, 0.05], b: [0.95, 0.78, 0.30], c: [0.70, 0.45, 0.15] },
    iridescent: { a: [0.10, 0.05, 0.20], b: [0.30, 0.85, 0.95], c: [0.95, 0.40, 0.85] }
  };

  function syncCanvasSize() {
    const rect = canvas.parentElement.getBoundingClientRect();
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = Math.round(rect.width * dpr);
    canvas.height = Math.round(rect.height * dpr);
    canvas.style.width = rect.width + 'px';
    canvas.style.height = rect.height + 'px';
    gl.viewport(0, 0, canvas.width, canvas.height);
  }
  syncCanvasSize();
  window.addEventListener('resize', syncCanvasSize);

  // === Pause when offscreen (perf) ===
  let visible = true;
  if ('IntersectionObserver' in window) {
    new IntersectionObserver((entries) => {
      entries.forEach(e => visible = e.isIntersecting);
    }, { threshold: 0.01 }).observe(canvas);
  }

  // === Render loop ===
  const start = performance.now();
  function render() {
    if (visible) {
      const t = (performance.now() - start) / 1000;
      const pal = PALETTES[settings.color] || PALETTES.silver;
      gl.uniform2f(uRes, canvas.width, canvas.height);
      gl.uniform1f(uTime, t);
      gl.uniform1f(uSpeed, settings.speed);
      gl.uniform1f(uDist, settings.distortion);
      gl.uniform1f(uLight, settings.lightAngle);
      gl.uniform3fv(uColA, pal.a);
      gl.uniform3fv(uColB, pal.b);
      gl.uniform3fv(uColC, pal.c);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }
    requestAnimationFrame(render);
  }
  render();
  } // fin initMetal

  var el = document.getElementById('psLiquidMetal');
  if (el) initMetal(el);
})();

Generative Flow Field

Champ vectoriel Perlin avec 5000 particules suivant les courants. Inspiration aurore boréale et courants océaniques. Canvas 2D pur, 60fps.

Canvas Generative Premium

WebGPU Particle Field

Galaxie de particules rendue en WebGPU : des dizaines de milliers de points animés sur le GPU, fluides et lumineux. Repli canvas 2D si le navigateur ne supporte pas encore WebGPU.

💡 Idéal pour : hero next-gen, fond de section premium, page de lancement tech, écran de chargement spectaculaire.

WebGPU WGSL Particles Premium

WebGPU Spirit Vortex

Nuée d'esprits rendue en WebGPU : ils flottent en ambiance puis sont happés en spirale autour du curseur, comme un portail qui s'ouvre. Repli canvas 2D si WebGPU absent.

💡 Idéal pour : hero Halloween/mystique, portail d'invocation, fond de section premium, écran d'intro de jeu, ambiance « au-delà ».

WebGPU WGSL Vortex Premium

Snow Globe

Boule à neige interactive en rendu semi-3D (verre, reflets, profondeur). Secouez-la pour faire tourbillonner une neige réellement contenue dans le globe, qui retombe et s'accumule au sol. Scène personnalisable.

💡 Idéal pour : carte de vœux, hero de Noël, easter-egg festif, page produit saisonnière, message interactif.

Canvas Physique Hiver Premium

Confetti Cannon

Deux canons premium aux coins bas propulsent des confettis en papier métallisé : rectangles avec éclairage par normale 3D (diffuse + glint spéculaire), rubans torsadés bicolores, étoiles et ronds répartis sur 3 couches de parallaxe. L'émission est étalée sur 150 ms avec éclair de bouche et étincelles à chaque tir. En mode prefers-reduced-motion, un unique tir court et doux se déclenche puis cesse.

💡 Idéal pour : confirmation de paiement premium, onboarding VIP, milestone abonnement, partage viral, écran de félicitations haut de gamme.

Canvas 2D Normale 3D Parallaxe 3 couches Glint spéculaire Rubans torsadés
Page de l’effet →
Voir le code source
HTML
<div class="demo-preview">

  <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">Cliquez n'importe où · ou</div>
    <button class="ccn-celebrate-btn" aria-label="Lancer les canons à confettis">🎉 Célébrer</button>
  </div>

</div>
CSS
.demo-preview,
.demo-preview *,
.demo-preview *::before,
.demo-preview *::after {
  box-sizing: border-box
}

.demo-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(10, 10, 26);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

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

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

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

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

@media (prefers-reduced-motion: reduce) {

  .ccn-celebrate-btn,
  .ccn-order-btn {
    transition: none;
  }

  .ccn-celebrate-btn:active {
    transform: translateX(-50%);
  }
}

@media (max-width:480px) {
  .ccn-celebrate-btn {
    font-size: 12px;
    padding: 7px 16px;
  }
}
JavaScript
(function() {
  /* 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
    };

  }());
})();

Balloon Release

Un canvas 2D unique rend des ballons semi-transparents (dégradé radial décalé, reflets spéculaires, liseré lumineux) avec une ficelle Verlet souple tracée en courbe lisse. Les ballons qui se chevauchent se teintent. Le pointeur les repousse doucement ; un clic fait éclater le ballon visé (fragments + flash). En mode accessibilité réduite : ballons statiques dessinés une seule fois.

💡 Idéal pour : bannières anniversaire SaaS (« 1 an avec nous »), pages de confirmation d'abonnement, messages de bienvenue, célébrations de jalons produit.

Canvas 2D Verlet Physics Translucent Latex Pointer Repulsion Click to Pop Premium

Success Celebration

Composant de confirmation orchestré : cercle SVG tracé progressivement depuis 12 h, coche dessinée, deux halos pulsants, burst de particules en anneau (Canvas 2D) et texte en stagger. Rejouable ; en mode réduit, coche et texte apparaissent instantanément sans aucune particule.

💡 Idéal pour : confirmation de paiement, sauvegarde de formulaire, succès d'onboarding SaaS, validation de commande ou fin de tutoriel interactif.

SVG stroke-dashoffset Canvas 2D Orchestration Stagger Accessible Premium

Reaction Burst

Barre de réactions façon live streaming : chaque clic envoie l'emoji en trajectoire bézier avec wobble sinus et rotation. Un flux simulé fait apparaître en continu des réactions d'autres spectateurs depuis le flanc droit. Pool recyclé (max 40 particules), compteurs animés. En mode prefers-reduced-motion : compteurs seuls, fondu sans trajectoire.

💡 Idéal pour : lecteur vidéo live, webinaire interactif, page de lancement produit, galerie de commentaires en temps réel, app communautaire.

DOM Pool Bezier Trajectory rAF Live Simulation Emoji Physics Premium

Sparkler Light Painting

Canvas 2D à deux couches : un buffer d'accumulation estompé chaque frame (destination-out α faible) crée la traînée lumineuse persistante façon pose longue. Le pointeur devient un cierge magique — halo orangé incandescent + étincelles jaillissantes à gravité légère et crépitement. Sans interaction, une main invisible trace un cœur paramétrique en boucle. Composition lighter pour l'accumulation additive ; sprites radiaux pré-renderés (pas de shadowBlur). Repli statique sans particules si mouvement réduit.

💡 Idéal pour : hero de lancement produit en thème sombre, page de vœux de fin d'année, section célébration premium, landing page événementielle ou anniversaire client SaaS.

Canvas 2D Accumulation Buffer lighter composite Particle System Long Exposure Premium

Questions fréquentes

Comment créer un système de particules en JavaScript ? +

Créez un tableau de particules avec position (x,y), vitesse (vx,vy) et taille. Dans une boucle requestAnimationFrame, mettez à jour les positions, dessinez chaque particule sur un canvas, et recyclez celles qui sortent de l'écran.

Combien de particules pour de bonnes performances ? +

Sur desktop, 200-500 particules simples (cercles) fonctionnent bien. Sur mobile, limitez à 50-100. Si chaque particule a un trail, divisez par 3. Mesurez avec les DevTools Performance panel pour votre cas spécifique.

Comment faire des particules qui réagissent à la souris ? +

Ajoutez un écouteur mousemove qui stocke la position du curseur. Dans la boucle de rendu, calculez la distance entre chaque particule et le curseur. Si elle est inférieure à un seuil, appliquez une force de répulsion ou d'attraction.

Quelle est la différence entre canvas et CSS pour les particules ? +

Canvas est meilleur pour les particules : un seul élément DOM, rendu pixel par pixel, très performant pour des centaines d'éléments. CSS crée un élément DOM par particule, ce qui est lourd au-delà de 50 éléments.

Comment optimiser un système de particules ? +

Utilisez un seul canvas, Object pooling (recycler les particules), évitez les allocations dans la boucle de rendu, utilisez ctx.clearRect au lieu de créer un nouveau canvas. Sur mobile, réduisez le nombre et la fréquence de mise à jour.

Catégories similaires