How do you create particle effects on a website?

Particle effects fill a scene with many small moving elements: stars, snow, rain, smoke, fire, confetti. They are drawn on an HTML canvas, where a JavaScript loop driven by requestAnimationFrame updates each particle’s position, speed and lifetime, then redraws the frame. That keeps hundreds of particles smooth where DOM elements would stall.

How to choose, performance and accessibility
  • Use Starfield or Snow Fall for ambient backgrounds, Confetti Explosion to celebrate a sign-up or a purchase, and Attraction Field when particles should react to the pointer.
  • Scale the particle count with the screen size, stop the loop when the canvas leaves the screen or the tab is hidden, and show a still frame for prefers-reduced-motion.
  • The page also offers interface sounds, such as Click Sound Button and Notification Chime, that play only after a user action.
  • Starfield, Confetti Explosion and several sound effects are free; the rest come with Premium.
Generator

Tune these particles yourself

The effects below can be copied as they are. The particle generator goes further: you set the shapes, the colours and the sequence, you feed it your own logo or text, and the result updates live — then you take the code away as a single self-contained file.

This is not a picture: the engine is really running, and it reacts to the cursor.

Open the generator

Starfield

Starfield moving toward the viewer with depth effect and luminous trails.

JS Canvas Stars
Effect page →
View source code
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

Falling snowflakes with varied sizes, horizontal oscillation and random opacity.

JS Canvas Snow

Fire Effect

Rising fire particles with color gradient (yellow to orange to red) and progressive dissipation.

JS Canvas Fire

Smoke

Rising smoke with progressive expansion, gentle lateral drift and fade-out transparency.

JS Canvas Smoke

Attraction Field

Particles attracted to the cursor with force proportional to distance. Lines connecting nearby particles.

JS Canvas Interactive

Explosion

Click to trigger a colorful particle explosion with gravity, friction and dissipation.

JS Canvas Click

Fireflies

Floating fireflies with individual light pulsation, erratic movement and light halo.

JS Canvas Glow

Rain

Raindrops falling diagonally with splash on impact and ground accumulation.

JS Canvas Weather

Galaxy Spiral

Spiral galaxy with rotating arms, stars of varied colors and luminous central core.

JS Canvas Galaxy

Trail Effect

Trail of colored particles following the cursor with rotation, shrinking and progressive fading.

JS Canvas Mouse

Ambient Light Glow

Element emitting a colored ambient light halo via multi-layer box-shadow with progressive spread.

CSS Animation Glow

Day/Night Cycle

Smooth transition through a full day/night cycle with animated gradients and moving sun.

CSS Animation Gradient

Fog Layer

Animated semi-transparent fog drifting across content via pseudo-elements and translateX.

CSS Pseudo Animation

Fireflies

Fireflies floating randomly with soft pulsating glow on dark background, pure CSS.

CSS Keyframes Particles

Northern Lights

Animated aurora borealis with multi-layer gradients, fluid movement and hue rotation.

CSS Gradient Hue-rotate

Dust Motes

Dust particles floating in a light beam with subtle random animations.

CSS Animation Particles

Heat Haze

Subtle wavy distortion simulating hot air via animated SVG feTurbulence filter.

SVG Filter Animation

Underwater Caustics

Animated light patterns as if beneath the water surface with conic gradients and rising bubbles.

CSS Gradient Animation

Snow Fall

Realistic snowfall with snowflakes of varied sizes and speeds, gentle wind drift, pure CSS.

CSS Keyframes Particles

Light Leak

Animated film-style light leaks and flares with gradients and mix-blend-mode screen.

CSS Blend Mode Animation

Morphing Blob

Organic shape that continuously deforms.

CSS Organic

Ripple Effect

Wave that propagates from the click point.

JS Material

Particle Explosion

Particles that explode in all directions.

JS Particles

Liquid Button

Liquid effect that fills the button.

CSS Hover

Matrix Rain

Matrix-style code rain effect.

JS Retro

Fireworks

Click to launch fireworks.

JS Celebration

Confetti Explosion

A radial explosion of metallic paper confetti bursts from the central button or any clicked point in the scene, then drifts back down for 2-3 seconds. Each piece — rectangle, circle, curved ribbon or star — carries a simulated 3D normal (diffuse lighting, dark edge, brief specular glint, two-tone faces). Two depth layers create space, and a few slow floaters keep the scene alive between bursts. Under prefers-reduced-motion, a single short, gentle burst fires then stops.

💡 Perfect for: goal-reached celebrations, user milestones, congratulations screens, unlocked badges, subscription activation.

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

  <div class="cxp-scene" id="cxpScene"
       role="img"
       aria-label="Confetti Explosion — radial burst of metallic confetti from the button"
       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">Click anywhere · or</div>
    <button class="cxp-btn" aria-label="Launch a confetti explosion">🎉 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();
    }

  })();
})();
Effect page →

Snow Fall

Snowflakes gently falling down.

JS Winter

Starfield

Space travel effect.

JS Space

Lightning

Random lightning bolts with flash effect.

JS Storm

Frequency Bars

Classic audio bars that react to the beat.

JS Classic
Effect page →
View source code
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

Bars arranged in a circle for a radial effect.

JS Radial

Wave Form

Continuously animated sine wave.

SVG Smooth

Pulse Ring

Rings that pulse outward to the beat.

JS Beat

Particle Burst

Particles that burst on the beats.

JS Explosive

Spectrum Gradient

Bars with dynamic color gradient.

JS Colorful

3D Bars

Bars with 3D perspective and shadows.

CSS 3D Depth

Blob Morph

Organic blob that deforms to sound.

SVG Organic

Line Graph

Animated continuous line with fill.

SVG Continuous

Neon Glow Bars

Bars with glowing neon effect.

CSS Glow

Sound Effects

Click Sound Button

Button with sound feedback generated by Web Audio API on click.

Web Audio Button Feedback
Effect page →
View source code
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);
    });
  })();
})();
🎵
Hover me

Hover Sound Card

Card that emits a subtle sound on hover via Web Audio API.

Web Audio Hover Card
Effect page →
View source code
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">Hover me</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

Text input with synthetically generated keyboard typing sound on each keystroke.

Web Audio Input Keyboard
✅ New message received!

Notification Chime

Notification with synthesized chime sound and appearance animation.

Web Audio Notification Chime
Effect page →
View source code
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; New message received!</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 with audible ticks on each value change in steps of 10.

Web Audio Slider Tick
OFF

Toggle Switch Sound

On/off switch with differentiated sounds for each state.

Web Audio Toggle UI
Effect page →
View source code
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

Ambient sound whose frequency varies based on scroll position.

Web Audio Scroll Ambient

Drag Feedback Sound

Continuous friction sound during drag, varying with movement speed.

Web Audio Drag Friction

Success/Error Tone

Synthesized success/error sounds using major and minor chords.

Web Audio Feedback UX
Effect page →
View source code
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; Success</button>
    <button class="snd-tone-btn snd-tone-error" id="sndError">&#10060; Error</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

Interactive soundscape: toggle ambient sound layers on and off.

Web Audio Ambient Interactive

Liquid Metal Shader

Liquid metallic surface in WebGL. Simplified raymarching, Perlin ripples, dynamic light reflection.

WebGL Shader Free
Effect page →
View source code
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

Perlin vector field with 5000 particles following the currents. Inspired by the northern lights and ocean currents. Pure Canvas 2D, 60fps.

Canvas Generative Premium

WebGPU Particle Field

Galaxy of particles rendered in WebGPU: tens of thousands of points animated on the GPU, fluid and luminous. Canvas 2D fallback if the browser doesn't support WebGPU yet.

💡 Perfect for: next-gen hero, premium section background, tech launch page, spectacular loading screen.

WebGPU WGSL Particles Premium

WebGPU Spirit Vortex

A swarm of spirits rendered in WebGPU: they drift ambientally then get pulled into a spiral around the cursor, like an opening invocation portal. Canvas 2D fallback if WebGPU is unavailable.

💡 Perfect for: Halloween/mystical hero, invocation portal, premium section background, game intro screen, otherworldly atmosphere.

WebGPU WGSL Vortex Premium

Snow Globe

Interactive semi-3D snow globe (glass, reflections, depth). Shake it to swirl snow truly contained inside the globe, which falls back down and accumulates on the ground. Customizable scene.

💡 Perfect for: greeting card, Christmas hero, festive easter egg, seasonal product page, interactive message.

Canvas Physics Winter Premium

Confetti Cannon

Two cannons in the bottom corners fire confetti diagonally toward the centre: rectangles with simulated 3D spin (two-tone front/back), wavy streamers (chained segments with lateral wobble) and dots, all under gravity, air drag and flutter. Fires when the scene enters the viewport, on click and via the ConfettiCannon.fire() API. Under prefers-reduced-motion: a single short, gentle burst on load, then nothing.

💡 Perfect for: order/payment confirmations, successful onboarding, user milestones, subscription anniversaries, viral share buttons.

Canvas 2D Normale 3D Parallaxe 3 couches Glint spéculaire Rubans torsadés
Effect page →
View source code
HTML
<div class="demo-preview">

  <div class="ccn-scene" id="ccnScene"
       role="img"
       aria-label="Premium Confetti Cannon — two cannons with 3D lighting, parallax and sparks"
       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">Click anywhere · or</div>
    <button class="ccn-celebrate-btn" aria-label="Fire the confetti cannons">🎉 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;
}

.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

A single Canvas 2D renders semi-transparent balloons (offset radial gradient, specular highlights, rim light) with a supple Verlet-chain string drawn as a smooth Catmull-Rom curve. Overlapping balloons tint each other; the pointer gently repels them and clicking pops one (fragments + flash). Recycled pool, rAF paused off-screen. Reduced motion: static balloons drawn once.

💡 Perfect for: SaaS anniversary banners (« 1 year with us »), subscription confirmation pages, onboarding welcome screens, product milestone celebrations.

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

Success Celebration

An orchestrated confirmation component: SVG circle drawn progressively from 12 o'clock, checkmark traced in, two pulsing halos, a ring burst of particles (Canvas 2D) and staggered text reveal. Compact toast and full « Payment confirmed » modal share the same code; replayable. In reduced-motion mode the checkmark and text appear instantly, with no particles.

💡 Perfect for: payment confirmations, form saves, SaaS onboarding success, order validation, end of an interactive tutorial.

SVG stroke-dashoffset Canvas 2D Orchestration Stagger Accessible Premium

Reaction Burst

Live-streaming reaction bar: each click launches an emoji along a cubic Bézier path with sine wobble and rotation. A simulated live feed keeps reactions from other viewers coming in from the right. Recycled DOM pool (max 40 particles), animated counters. With prefers-reduced-motion: counters only, plain fade — no trajectory.

💡 Perfect for: live video players, interactive webinars, product launch pages, real-time community feeds, social comment streams.

DOM Pool Bezier Trajectory rAF Live Simulation Emoji Physics Premium

Sparkler Light Painting

Two-layer canvas: an accumulation buffer faded each frame (destination-out, low alpha) holds the persistent light-painting trail. Your cursor becomes a sparkler — incandescent white-orange halo, jetting sparks with gravity and individual flicker. Without interaction, an invisible hand loops a glowing parametric heart. Additive compositing, pre-rendered radial sprites, no shadowBlur. Static outline if motion is reduced.

💡 Perfect for: dark product-launch heroes, New Year greeting pages, premium celebration sections, event landing pages, SaaS anniversary banners.

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

Frequently Asked Questions

How to create a particle system in JavaScript? +

Create an array of particles with position (x,y), velocity (vx,vy) and size. In a requestAnimationFrame loop, update positions, draw each particle on a canvas, and recycle those that leave the screen.

How many particles for good performance? +

On desktop, 200-500 simple particles (circles) work well. On mobile, limit to 50-100. If each particle has a trail, divide by 3. Measure with the DevTools Performance panel for your specific case.

How to make particles react to the mouse? +

Add a mousemove listener that stores the cursor position. In the render loop, calculate the distance between each particle and the cursor. If it is below a threshold, apply a repulsion or attraction force.

What is the difference between canvas and CSS for particles? +

Canvas is better for particles: a single DOM element, pixel-by-pixel rendering, very performant for hundreds of elements. CSS creates one DOM element per particle, which becomes heavy beyond 50 elements.

How to optimize a particle system? +

Use a single canvas, object pooling (recycle particles), avoid allocations in the render loop, use ctx.clearRect instead of creating a new canvas. On mobile, reduce the count and update frequency.

Similar Categories