Particules, Canvas & Atmosphère
Systèmes de particules, effets atmosphériques, spéciaux et audio.
Mis à jour le
Comment créer des effets de particules sur un site web ?
Les effets de particules remplissent une scène de nombreux petits éléments en mouvement : étoiles, neige, pluie, fumée, feu, confettis. Ils sont dessinés dans un élément canvas HTML, où une boucle JavaScript pilotée par requestAnimationFrame met à jour la position, la vitesse et la durée de vie de chaque particule, puis redessine l'image. Des centaines de particules restent ainsi fluides, là où des éléments du DOM ralentiraient.
Comment choisir, performances et accessibilité
- Utilisez Starfield ou Snow Fall pour un fond d'ambiance, Confetti Explosion pour célébrer une inscription ou un achat, et Attraction Field quand les particules doivent réagir au pointeur.
- Adaptez le nombre de particules à la taille de l'écran, arrêtez la boucle quand le canvas sort de l'écran ou que l'onglet est masqué, et affichez une image fixe pour
prefers-reduced-motion. - La page propose aussi des sons d'interface, comme Click Sound Button et Notification Chime, qui ne se déclenchent qu'après une action de l'utilisateur.
- Starfield, Confetti Explosion et plusieurs effets sonores sont gratuits ; les autres sont inclus dans Premium.
Ces particules, réglez-les vous-même
Les effets ci-dessous se copient tels quels. Le générateur de particules va plus loin : vous réglez les formes, les couleurs et l'enchaînement, vous lui donnez votre logo ou votre texte, et le résultat se voit en direct — puis vous emportez le code, en un fichier autonome.
Ce n'est pas une image : le moteur tourne pour de vrai, et il réagit au curseur.
Ouvrir le générateurStarfield
Champ d'étoiles en mouvement vers le spectateur avec effet de profondeur et traînées lumineuses.
Page de l’effet →Voir le code source
<div class="demo-preview">
<div class="ps-canvas-wrap">
<canvas class="ps-canvas" id="psStarfield"></canvas>
</div>
</div>.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%;
}(function() {
/* fx-0485 — Starfield (IIFE scopée) — restauré le 11/08/2026 : le code de cet effet
gratuit (data-free) avait été retiré par erreur avec le code premium (anti-scalping #13). */
(function() {
const canvas = document.getElementById('psStarfield');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
const w = canvas.width,
h = canvas.height;
const stars = [];
for (let i = 0; i < 200; i++) {
stars.push({
x: Math.random() * w - w / 2,
y: Math.random() * h - h / 2,
z: Math.random() * w
});
}
function draw() {
ctx.fillStyle = 'rgba(10,10,15,0.2)';
ctx.fillRect(0, 0, w, h);
stars.forEach(s => {
s.z -= 2;
if (s.z <= 0) {
s.z = w;
s.x = Math.random() * w - w / 2;
s.y = Math.random() * h - h / 2;
}
const sx = (s.x / s.z) * w / 2 + w / 2;
const sy = (s.y / s.z) * h / 2 + h / 2;
const r = Math.max(0.5, (1 - s.z / w) * 2.5);
const a = 1 - s.z / w;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${a})`;
ctx.fill();
});
requestAnimationFrame(draw);
}
draw();
})();
})();Snow Fall
Flocons de neige tombant avec tailles variées, oscillation horizontale et opacité aléatoire.
Page de l’effet →Fire Effect
Particules de feu montantes avec dégradé de couleur (jaune→orange→rouge) et dissipation progressive.
Page de l’effet →Smoke
Fumée s'élevant avec expansion progressive, dérive latérale douce et disparition en transparence.
Page de l’effet →Attraction Field
Particules attirées vers le curseur avec force proportionnelle à la distance. Liens entre particules proches.
Page de l’effet →Explosion
Cliquez pour déclencher une explosion de particules colorées avec gravité, friction et dissipation.
Page de l’effet →Fireflies
Lucioles flottantes avec pulsation lumineuse individuelle, mouvement erratique et halo de lumière.
Page de l’effet →Rain
Gouttes de pluie tombant en diagonale avec splash à l'impact et accumulation au sol.
Page de l’effet →Galaxy Spiral
Galaxie spirale avec bras en rotation, étoiles de couleurs variées et noyau lumineux central.
Page de l’effet →Trail Effect
Traînée de particules colorées suivant le curseur avec rotation, réduction et disparition progressive.
Page de l’effet →Ambient Light Glow
Élément émettant un halo lumineux ambiant coloré via box-shadow multi-couches avec spread progressif.
Page de l’effet →Day/Night Cycle
Transition fluide à travers un cycle complet jour/nuit avec dégradés animés et soleil en mouvement.
Page de l’effet →Fog Layer
Brouillard semi-transparent animé dérivant à travers le contenu via pseudo-éléments et translateX.
Page de l’effet →Fireflies
Lucioles flottant aléatoirement avec lueur douce pulsante sur fond sombre, purement CSS.
Page de l’effet →Northern Lights
Aurore boréale animée avec dégradés multi-couches, mouvement fluide et rotation de teinte.
Page de l’effet →Dust Motes
Particules de poussière flottant dans un rayon de lumière avec animations aléatoires subtiles.
Page de l’effet →Heat Haze
Distorsion subtile ondulante simulant l'air chaud via filtre SVG feTurbulence animé.
Page de l’effet →Underwater Caustics
Motifs lumineux animés comme sous la surface de l'eau avec dégradés coniques et bulles montantes.
Page de l’effet →Snow Fall
Chute de neige réaliste avec flocons de tailles et vitesses variées, dérive légère du vent, pur CSS.
Page de l’effet →Light Leak
Fuites de lumière et flares animés style film avec dégradés et mix-blend-mode screen.
Page de l’effet →Confetti Explosion
Une explosion radiale de confettis en papier métallisé jaillit depuis le bouton central ou depuis n'importe quel point cliqué dans la scène. Chaque particule — rectangle, rond, ruban courbé ou étoile — possède une normale 3D simulée qui génère un éclairage diffuse, une tranche sombre et un glint spéculaire bref, avec deux tons face/dos. Deux couches de profondeur créent la sensation d'espace. En mode prefers-reduced-motion, une unique explosion courte et douce se déclenche puis cesse.
💡 Idéal pour : célébration d'objectif atteint, milestone utilisateur, écran de félicitations, badge débloqué, activation d'abonnement.
Voir le code source
<div class="demo-preview">
<div class="cxp-scene" id="cxpScene"
role="img"
aria-label="Confetti Explosion — explosion radiale de confettis métallisés depuis le bouton"
data-colors="#FF4D9E,#FFD700,#00E5FF,#8AFF59,#F0F4F8,#FF8040,#E040FB,#FDFFB6">
<canvas class="cxp-canvas" aria-hidden="true"></canvas>
<div class="cxp-label" aria-hidden="true">Cliquez n'importe où · ou</div>
<button class="cxp-btn" aria-label="Lancer une explosion de confettis">🎉 Celebrate!</button>
</div>
</div>.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);
}
}(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();
}
})();
})();Voir le code source
<div class="demo-preview">
<div class="frequency-bars" id="frequencyBars"></div>
</div>.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;
}(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);
})();
})();Effets Sonores
Voir le code source
<div class="demo-preview" style="display: flex; align-items: center; justify-content: center;">
<button class="snd-click-btn" id="sndClickBtn">
<span class="snd-wave">🔊</span> Click Me
</button>
</div>.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;
}(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);
});
})();
})();Voir le code source
<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">🎵</div>
<div class="snd-hover-label">Survolez-moi</div>
</div>
</div>.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);
}(function() {
(function() {
var carte = document.getElementById('sndHoverCard');
if (!carte) return;
var ctx = null,
dernier = 0;
function audio() {
return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
}
carte.addEventListener('mouseenter', function() {
if (Date.now() - dernier < 250) return; // pas de mitraillage au survol
dernier = Date.now();
var a = audio(),
t = a.currentTime;
var o = a.createOscillator(),
g = a.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(523.25, t); // do5
o.frequency.linearRampToValueAtTime(659.25, t + 0.12);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.1, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
o.connect(g).connect(a.destination);
o.start(t);
o.stop(t + 0.24);
});
})();
})();Typing Sound Input
Champ de texte avec son de frappe clavier génère synthétiquement à chaque touche.
Page de l’effet →Notification Chime
Notification avec son de cloche synthétisé et animation d'apparition.
Page de l’effet →Voir le code source
<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">🔔 Notification</button>
<div class="snd-notif-toast" id="sndNotifToast">✅ Nouveau message recu !</div>
</div>
</div>.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);
}(function() {
(function() {
var btn = document.getElementById('sndNotifBtn'),
toast = document.getElementById('sndNotifToast');
if (!btn || !toast) return;
var ctx = null,
minuteur = null;
function audio() {
return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
}
function note(a, freq, depart, duree, vol) {
var o = a.createOscillator(),
g = a.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(freq, depart);
g.gain.setValueAtTime(0.0001, depart);
g.gain.exponentialRampToValueAtTime(vol, depart + 0.015);
g.gain.exponentialRampToValueAtTime(0.0001, depart + duree);
o.connect(g).connect(a.destination);
o.start(depart);
o.stop(depart + duree + 0.02);
}
btn.addEventListener('click', function() {
var a = audio(),
t = a.currentTime;
note(a, 880, t, 0.18, 0.12); // la5
note(a, 1318.5, t + 0.1, 0.35, 0.1); // mi6 — carillon a deux notes
toast.classList.add('show');
clearTimeout(minuteur);
minuteur = setTimeout(function() {
toast.classList.remove('show');
}, 2600);
});
})();
})();Slider Tick Sound
Slider avec ticks sonores à chaque changement de valeur par pas de 10.
Page de l’effet →Voir le code source
<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>.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);
}(function() {
(function() {
var inter = document.getElementById('sndToggle'),
label = document.getElementById('sndToggleLabel');
if (!inter) return;
var ctx = null;
function audio() {
return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
}
inter.addEventListener('click', function() {
var allume = inter.classList.toggle('on');
if (label) label.textContent = allume ? 'ON' : 'OFF';
var a = audio(),
t = a.currentTime;
var o = a.createOscillator(),
g = a.createGain();
o.type = 'square';
// montee a l'allumage, descente a l'extinction : le son dit l'etat
o.frequency.setValueAtTime(allume ? 420 : 760, t);
o.frequency.exponentialRampToValueAtTime(allume ? 760 : 420, t + 0.07);
g.gain.setValueAtTime(0.07, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.11);
o.connect(g).connect(a.destination);
o.start(t);
o.stop(t + 0.12);
});
})();
})();Scroll Ambient Sound
Son d'ambiance dont la fréquence varie en fonction de la position de scroll.
Page de l’effet →Drag Feedback Sound
Son de friction continu pendant le drag, variant avec la vitesse de déplacement.
Page de l’effet →Success/Error Tone
Sons de validation/erreur synthétisés avec des accords majeurs et mineurs.
Page de l’effet →Voir le code source
<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">✅ Succes</button>
<button class="snd-tone-btn snd-tone-error" id="sndError">❌ Erreur</button>
</div>
</div>.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);
}(function() {
(function() {
var ok = document.getElementById('sndSuccess'),
ko = document.getElementById('sndError');
if (!ok && !ko) return;
var ctx = null;
function audio() {
return ctx || (ctx = new(window.AudioContext || window.webkitAudioContext)());
}
function jouer(freqs, type, pas, duree, vol) {
var a = audio(),
t = a.currentTime;
freqs.forEach(function(f, i) {
var o = a.createOscillator(),
g = a.createGain(),
d = t + i * pas;
o.type = type;
o.frequency.setValueAtTime(f, d);
g.gain.setValueAtTime(0.0001, d);
g.gain.exponentialRampToValueAtTime(vol, d + 0.015);
g.gain.exponentialRampToValueAtTime(0.0001, d + duree);
o.connect(g).connect(a.destination);
o.start(d);
o.stop(d + duree + 0.02);
});
}
// arpege majeur qui monte = reussite ; deux notes basses qui descendent = erreur
if (ok) ok.addEventListener('click', function() {
jouer([523.25, 659.25, 783.99], 'sine', 0.09, 0.22, 0.1);
});
if (ko) ko.addEventListener('click', function() {
jouer([311.13, 233.08], 'sawtooth', 0.13, 0.3, 0.07);
});
})();
})();Ambient Soundscape
Paysage sonore interactif : activez/désactivez des couches de sons d'ambiance.
Page de l’effet →Liquid Metal Shader
Surface métallique liquide en WebGL. Raymarching simplifié, ondulations Perlin, réflexion lumière dynamique.
Page de l’effet →Voir le code source
<div class="ps-canvas-wrap">
<canvas class="ps-canvas" id="psLiquidMetal"></canvas>
</div>.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%;
}(function() {
var settings = { color: 'silver', speed: 1.0, distortion: 0.6, lightAngle: 45 };
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function initMetal(canvas) {
// Reduced motion : fallback CSS gradient déjà en place via @media
if (reduceMotion) return;
const gl = canvas.getContext('webgl', { antialias: false, alpha: false }) ||
canvas.getContext('experimental-webgl');
// WebGL not supported → CSS gradient fallback (handled in CSS @media query)
if (!gl) {
canvas.style.display = 'none';
return;
}
// === Vertex shader : fullscreen quad ===
const VS = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;
// === Fragment shader : liquid metal via Perlin noise + raymarching simplifié ===
const FS = `
precision highp float;
uniform vec2 u_res;
uniform float u_time;
uniform float u_speed;
uniform float u_distortion;
uniform float u_light_angle;
uniform vec3 u_color_a;
uniform vec3 u_color_b;
uniform vec3 u_color_c;
// Classic 2D simplex noise (Ashima Arts, MIT)
vec3 mod289(vec3 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0,0.0) : vec2(0.0,1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0))
+ i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m; m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
// Octave fbm for richer surface
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * snoise(p);
p *= 2.0;
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / min(u_res.x, u_res.y);
float t = u_time * u_speed * 0.3;
// Surface height via fbm
float h = fbm(uv * 2.0 + vec2(t, t * 0.7)) * u_distortion;
// Surface normal via gradient (finite differences)
float eps = 0.01;
float hx = fbm((uv + vec2(eps,0.0)) * 2.0 + vec2(t, t*0.7)) * u_distortion;
float hy = fbm((uv + vec2(0.0,eps)) * 2.0 + vec2(t, t*0.7)) * u_distortion;
vec3 normal = normalize(vec3((h - hx)/eps, (h - hy)/eps, 1.0));
// Light direction
float angle = radians(u_light_angle);
vec3 lightDir = normalize(vec3(cos(angle), sin(angle), 0.6));
// Specular metal reflection
float diffuse = max(dot(normal, lightDir), 0.0);
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
// Iridescence factor (used by gold/iridescent presets)
float irid = 0.5 + 0.5 * sin(h * 8.0 + t * 2.0);
// Mix three colour stops based on diffuse + iridescence
vec3 base = mix(u_color_a, u_color_b, diffuse);
base = mix(base, u_color_c, irid * 0.5);
// Metal gloss : add specular highlight
vec3 finalColor = base + vec3(spec * 1.2);
// Vignette subtle
float vig = 1.0 - 0.4 * length(uv);
finalColor *= vig;
gl_FragColor = vec4(finalColor, 1.0);
}
`;
// === Compile + link program ===
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(sh));
gl.deleteShader(sh);
return null;
}
return sh;
}
const vs = compile(gl.VERTEX_SHADER, VS);
const fs = compile(gl.FRAGMENT_SHADER, FS);
if (!vs || !fs) return;
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(prog));
return;
}
gl.useProgram(prog);
// === Fullscreen quad ===
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// === Uniforms ===
const uRes = gl.getUniformLocation(prog, 'u_res');
const uTime = gl.getUniformLocation(prog, 'u_time');
const uSpeed = gl.getUniformLocation(prog, 'u_speed');
const uDist = gl.getUniformLocation(prog, 'u_distortion');
const uLight = gl.getUniformLocation(prog, 'u_light_angle');
const uColA = gl.getUniformLocation(prog, 'u_color_a');
const uColB = gl.getUniformLocation(prog, 'u_color_b');
const uColC = gl.getUniformLocation(prog, 'u_color_c');
// === Color presets ===
const PALETTES = {
silver: { a: [0.10, 0.11, 0.13], b: [0.85, 0.88, 0.92], c: [0.55, 0.60, 0.70] },
gold: { a: [0.20, 0.13, 0.05], b: [0.95, 0.78, 0.30], c: [0.70, 0.45, 0.15] },
iridescent: { a: [0.10, 0.05, 0.20], b: [0.30, 0.85, 0.95], c: [0.95, 0.40, 0.85] }
};
function syncCanvasSize() {
const rect = canvas.parentElement.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
gl.viewport(0, 0, canvas.width, canvas.height);
}
syncCanvasSize();
window.addEventListener('resize', syncCanvasSize);
// === Pause when offscreen (perf) ===
let visible = true;
if ('IntersectionObserver' in window) {
new IntersectionObserver((entries) => {
entries.forEach(e => visible = e.isIntersecting);
}, { threshold: 0.01 }).observe(canvas);
}
// === Render loop ===
const start = performance.now();
function render() {
if (visible) {
const t = (performance.now() - start) / 1000;
const pal = PALETTES[settings.color] || PALETTES.silver;
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform1f(uTime, t);
gl.uniform1f(uSpeed, settings.speed);
gl.uniform1f(uDist, settings.distortion);
gl.uniform1f(uLight, settings.lightAngle);
gl.uniform3fv(uColA, pal.a);
gl.uniform3fv(uColB, pal.b);
gl.uniform3fv(uColC, pal.c);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
requestAnimationFrame(render);
}
render();
} // fin initMetal
var el = document.getElementById('psLiquidMetal');
if (el) initMetal(el);
})();Generative Flow Field
Champ vectoriel Perlin avec 5000 particules suivant les courants. Inspiration aurore boréale et courants océaniques. Canvas 2D pur, 60fps.
Page de l’effet →WebGPU Particle Field
Galaxie de particules rendue en WebGPU : des dizaines de milliers de points animés sur le GPU, fluides et lumineux. Repli canvas 2D si le navigateur ne supporte pas encore WebGPU.
💡 Idéal pour : hero next-gen, fond de section premium, page de lancement tech, écran de chargement spectaculaire.
WebGPU Spirit Vortex
Nuée d'esprits rendue en WebGPU : ils flottent en ambiance puis sont happés en spirale autour du curseur, comme un portail qui s'ouvre. Repli canvas 2D si WebGPU absent.
💡 Idéal pour : hero Halloween/mystique, portail d'invocation, fond de section premium, écran d'intro de jeu, ambiance « au-delà ».
Snow Globe
Boule à neige interactive en rendu semi-3D (verre, reflets, profondeur). Secouez-la pour faire tourbillonner une neige réellement contenue dans le globe, qui retombe et s'accumule au sol. Scène personnalisable.
💡 Idéal pour : carte de vœux, hero de Noël, easter-egg festif, page produit saisonnière, message interactif.
Confetti Cannon
Deux canons premium aux coins bas propulsent des confettis en papier métallisé : rectangles avec éclairage par normale 3D (diffuse + glint spéculaire), rubans torsadés bicolores, étoiles et ronds répartis sur 3 couches de parallaxe. L'émission est étalée sur 150 ms avec éclair de bouche et étincelles à chaque tir. En mode prefers-reduced-motion, un unique tir court et doux se déclenche puis cesse.
💡 Idéal pour : confirmation de paiement premium, onboarding VIP, milestone abonnement, partage viral, écran de félicitations haut de gamme.
Page de l’effet →Voir le code source
<div class="demo-preview">
<div class="ccn-scene" id="ccnScene"
role="img"
aria-label="Confetti Cannon premium — deux canons avec éclairage 3D, parallaxe et étincelles"
data-colors="#FF6B6B,#FFD166,#06D6A0,#9B5DE5,#F0F4F8,#FF8E53,#00BBF9,#F15BB5,#FEE440,#FDFFB6">
<canvas class="ccn-canvas" aria-hidden="true"></canvas>
<div class="ccn-label" aria-hidden="true">Cliquez n'importe où · ou</div>
<button class="ccn-celebrate-btn" aria-label="Lancer les canons à confettis">🎉 Célébrer</button>
</div>
</div>.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;
}
}(function() {
/* fx-0836 */
(function() {
'use strict';
var REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var PI2 = Math.PI * 2;
var PI = Math.PI;
/* ── Physique ──────────────────────────────────────────────────────────── */
var G = 0.17; // gravité (légèrement moins pour flottement premium)
var DRAG = 0.976; // traînée aérienne
var WOB = 0.42; // amplitude flottement feuille morte
/* ── Vecteur lumière normalisé (haut-gauche-avant) ─────────────────────── */
var LX = 0.32,
LY = -0.58,
LZ = 0.75;
/* ── 3 couches de profondeur ─────────────────────────────────────────────
scale : facteur taille particule
vs : facteur vitesse initiale
alpha : opacité de base
blur : coefficient de motion-blur (0 = aucun) */
var LAYERS = [{
scale: 0.44,
vs: 0.54,
alpha: 0.50,
blur: 0
}, /* 0 = lointain */
{
scale: 0.82,
vs: 0.84,
alpha: 0.84,
blur: 0
}, /* 1 = milieu */
{
scale: 1.28,
vs: 1.32,
alpha: 1.00,
blur: 0.30
} /* 2 = proche */
];
/* ── Palette premium par défaut ──────────────────────────────────────────
Corail · Or · Turquoise · Violet · Blanc nacré · Orange · Cyan · Rose · Jaune · Crème */
var COLORS_DEFAULT = ['#FF6B6B', '#FFD166', '#06D6A0', '#9B5DE5', '#F0F4F8', '#FF8E53', '#00BBF9', '#F15BB5', '#FEE440', '#FDFFB6'];
var MAX_PARTS = 450;
/* ── Utilitaires ─────────────────────────────────────────────────────────*/
function rnd(a, b) {
return a + Math.random() * (b - a);
}
function pick(arr) {
return arr[(Math.random() * arr.length) | 0];
}
function clamp(v, a, b) {
return v < a ? a : v > b ? b : v;
}
function sgn() {
return Math.random() < 0.5 ? 1 : -1;
}
/* ── Couleur sombre (dos de particule) ───────────────────────────────────*/
function hexDark(hex) {
var r = parseInt(hex.slice(1, 3), 16),
g = parseInt(hex.slice(3, 5), 16),
b = parseInt(hex.slice(5, 7), 16);
return 'rgb(' + Math.max(0, r - 60) + ',' + Math.max(0, g - 60) + ',' + Math.max(0, b - 60) + ')';
}
/* ── Applique éclairage : diff ∈ [-1,1] → luminosité +/- 50% ─────────── */
function applyLight(hex, diff) {
var r = parseInt(hex.slice(1, 3), 16),
g = parseInt(hex.slice(3, 5), 16),
b = parseInt(hex.slice(5, 7), 16);
var f = 1 + clamp(diff, -1, 1) * 0.50;
return 'rgb(' + Math.round(clamp(r * f, 0, 255)) + ',' + Math.round(clamp(g * f, 0, 255)) + ',' + Math.round(clamp(b * f, 0, 255)) + ')';
}
/* ── Fabricants de particules ─────────────────────────────────────────── */
/* Rectangle avec éclairage 3D (rotX + rotY) */
function mkRect(x, y, vx, vy, col, layer) {
var L = LAYERS[layer];
return {
t: 'r',
x: x,
y: y,
vx: vx * L.vs,
vy: vy * L.vs,
w: rnd(10, 20) * L.scale,
h: rnd(4, 9) * L.scale,
rotY: rnd(0, PI2),
rotX: rnd(0, PI2),
spinY: rnd(0.05, 0.14) * sgn(),
spinX: rnd(0.03, 0.08) * sgn(),
wph: rnd(0, PI2),
wfr: rnd(0.04, 0.09),
col: col,
back: hexDark(col),
layer: layer,
alpha: L.alpha,
life: 1,
decay: rnd(0.0011, 0.0032)
};
}
/* Carré (rect carré) */
function mkSquare(x, y, vx, vy, col, layer) {
var L = LAYERS[layer],
s = rnd(5, 13) * L.scale;
return {
t: 'r',
x: x,
y: y,
vx: vx * L.vs,
vy: vy * L.vs,
w: s,
h: s,
rotY: rnd(0, PI2),
rotX: rnd(0, PI2),
spinY: rnd(0.05, 0.16) * sgn(),
spinX: rnd(0.03, 0.09) * sgn(),
wph: rnd(0, PI2),
wfr: rnd(0.04, 0.09),
col: col,
back: hexDark(col),
layer: layer,
alpha: L.alpha,
life: 1,
decay: rnd(0.0012, 0.0034)
};
}
/* Cercle */
function mkCircle(x, y, vx, vy, col, layer) {
var L = LAYERS[layer];
return {
t: 'c',
x: x,
y: y,
vx: vx * L.vs,
vy: vy * L.vs,
r: rnd(3, 6.5) * L.scale,
wph: rnd(0, PI2),
wfr: rnd(0.05, 0.10),
col: col,
layer: layer,
alpha: L.alpha,
life: 1,
decay: rnd(0.0018, 0.0045)
};
}
/* Étoile 5 branches */
function mkStar(x, y, vx, vy, col, layer) {
var L = LAYERS[layer];
return {
t: 'star',
x: x,
y: y,
vx: vx * L.vs,
vy: vy * L.vs,
r: rnd(5, 9) * L.scale,
theta: rnd(0, PI2),
spin: rnd(0.06, 0.18) * sgn(),
wph: rnd(0, PI2),
wfr: rnd(0.04, 0.09),
col: col,
layer: layer,
alpha: L.alpha,
life: 1,
decay: rnd(0.0015, 0.0038)
};
}
/* Ruban torsadé (serpentin) */
function mkSerpentin(x, y, vx, vy, col, layer) {
var L = LAYERS[layer],
n = (rnd(7, 13) | 0),
seg = [];
for (var i = 0; i < n; i++) seg.push({
x: x,
y: y
});
return {
t: 's',
x: x,
y: y,
vx: vx * L.vs,
vy: vy * L.vs,
segs: seg,
n: n,
slen: rnd(5, 12) * L.scale,
ph: rnd(0, PI2),
wfr: rnd(0.06, 0.12),
lw: rnd(2.5, 5) * L.scale,
col: col,
back: hexDark(col),
twspd: rnd(0.05, 0.14),
/* vitesse de torsion */
tck: 0,
layer: layer,
alpha: L.alpha,
life: 1,
decay: rnd(0.0011, 0.0030)
};
}
/* Étincelle de bouche (vie très courte, bright) */
function mkSpark(x, y, mainAng) {
var a = mainAng + (Math.random() - 0.5) * 1.4,
v = rnd(9, 20);
return {
t: 'spark',
x: x,
y: y,
vx: Math.cos(a) * v,
vy: Math.sin(a) * v,
r: rnd(1.5, 3.5),
col: '#FFF9C4',
life: 1,
decay: rnd(0.05, 0.12),
layer: 2,
alpha: 1
};
}
/* Bruine ambiante – mkAmb* : particules recyclées au passage en bas */
function mkAmbRect(W, col) {
return {
t: 'r',
amb: true,
alpha: rnd(0.40, 0.62),
x: rnd(0, W),
y: rnd(-30, -5),
vx: rnd(-0.7, 0.7),
vy: rnd(1.1, 2.6),
w: rnd(5, 12),
h: rnd(3, 7),
rotY: rnd(0, PI2),
rotX: rnd(0, PI2),
spinY: rnd(0.02, 0.06) * sgn(),
spinX: rnd(0.01, 0.04) * sgn(),
wph: rnd(0, PI2),
wfr: rnd(0.03, 0.07),
col: col,
back: hexDark(col),
layer: 1,
life: 1,
decay: 0
};
}
function mkAmbCircle(W, col) {
return {
t: 'c',
amb: true,
alpha: rnd(0.40, 0.62),
x: rnd(0, W),
y: rnd(-30, -5),
vx: rnd(-0.4, 0.4),
vy: rnd(1.1, 2.6),
r: rnd(1.5, 4),
wph: rnd(0, PI2),
wfr: rnd(0.04, 0.08),
col: col,
layer: 1,
life: 1,
decay: 0
};
}
function mkAmbStar(W, col) {
return {
t: 'star',
amb: true,
alpha: rnd(0.40, 0.62),
x: rnd(0, W),
y: rnd(-30, -5),
vx: rnd(-0.3, 0.3),
vy: rnd(0.9, 2.0),
r: rnd(2.5, 4.5),
theta: rnd(0, PI2),
spin: rnd(0.01, 0.04) * sgn(),
wph: rnd(0, PI2),
wfr: rnd(0.03, 0.07),
col: col,
layer: 1,
life: 1,
decay: 0
};
}
function mkAmbSerpentin(W, col) {
var n = (rnd(3, 6) | 0),
sx = rnd(0, W),
sy = rnd(-30, -5),
seg = [];
for (var i = 0; i < n; i++) seg.push({
x: sx,
y: sy
});
return {
t: 's',
amb: true,
alpha: rnd(0.40, 0.62),
x: sx,
y: sy,
vx: rnd(-0.4, 0.4),
vy: rnd(0.9, 2.0),
segs: seg,
n: n,
slen: rnd(4, 8),
ph: rnd(0, PI2),
wfr: rnd(0.04, 0.09),
lw: rnd(1.5, 3),
col: col,
back: hexDark(col),
twspd: rnd(0.03, 0.08),
tck: 0,
layer: 1,
life: 1,
decay: 0
};
}
/* ── Reset particules ambiantes ──────────────────────────────────────────*/
function resetAmb(p, W) {
p.x = rnd(0, W);
p.y = rnd(-30, -5);
p.vx = rnd(-0.7, 0.7);
p.vy = rnd(1.1, 2.6);
p.alpha = rnd(0.40, 0.62);
}
function resetAmbC(p, W) {
p.x = rnd(0, W);
p.y = rnd(-30, -5);
p.vx = rnd(-0.4, 0.4);
p.vy = rnd(1.1, 2.6);
p.alpha = rnd(0.40, 0.62);
}
function resetAmbStar(p, W) {
p.x = rnd(0, W);
p.y = rnd(-30, -5);
p.vx = rnd(-0.3, 0.3);
p.vy = rnd(0.9, 2.0);
p.alpha = rnd(0.40, 0.62);
}
function resetAmbSerpentin(p, W) {
p.x = rnd(0, W);
p.y = rnd(-30, -5);
for (var k = 0; k < p.n; k++) {
p.segs[k].x = p.x;
p.segs[k].y = p.y;
}
p.vx = rnd(-0.4, 0.4);
p.vy = rnd(0.9, 2.0);
p.alpha = rnd(0.25, 0.48);
}
/* ── Update ──────────────────────────────────────────────────────────────*/
function upRect(p, dt) {
var dd = Math.pow(DRAG, dt);
p.vy = (p.vy + G * dt) * dd;
p.vx *= dd;
p.wph += p.wfr * dt;
p.vx += Math.sin(p.wph) * WOB * 0.10 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rotY += p.spinY * dt;
p.rotX += p.spinX * dt;
p.life -= p.decay * dt;
}
function upCircle(p, dt) {
var dd = Math.pow(DRAG, dt);
p.vy = (p.vy + G * dt) * dd;
p.vx *= dd;
p.wph += p.wfr * dt;
p.vx += Math.sin(p.wph) * WOB * 0.08 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= p.decay * dt;
}
function upStar(p, dt) {
var dd = Math.pow(DRAG, dt);
p.vy = (p.vy + G * dt) * dd;
p.vx *= dd;
p.wph += p.wfr * dt;
p.vx += Math.sin(p.wph) * WOB * 0.09 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.theta += p.spin * dt;
p.life -= p.decay * dt;
}
function upSerpentin(p, dt) {
var dd = Math.pow(DRAG, dt);
p.vy = (p.vy + G * 0.60 * dt) * dd;
p.vx *= dd;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.tck += dt;
p.segs[0].x = p.x;
p.segs[0].y = p.y;
for (var i = 1; i < p.n; i++) {
var dx = p.segs[i - 1].x - p.segs[i].x,
dy = p.segs[i - 1].y - p.segs[i].y;
var d = Math.sqrt(dx * dx + dy * dy);
if (d > p.slen) {
var pull = (d - p.slen) / d * 0.80;
p.segs[i].x += dx * pull;
p.segs[i].y += dy * pull;
}
p.segs[i].x += Math.sin(p.tck * p.wfr + i * 0.75 + p.ph) * 0.80 * dt;
}
p.life -= p.decay * dt;
}
function upSpark(p, dt) {
p.vy += G * 1.8 * dt;
p.vx *= 0.90;
p.vy *= 0.90;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= p.decay * dt;
}
/* ── Draw ─────────────────────────────────────────────────────────────── */
/* Rectangle 3D : éclairage normal + glint spéculaire + motion-blur couche proche */
function drRect(ctx, p, lAlpha) {
var cosY = Math.cos(p.rotY),
cosX = Math.cos(p.rotX);
var sinY = Math.sin(p.rotY),
sinX = Math.sin(p.rotX);
var visW = p.w * Math.abs(cosY),
visH = p.h * Math.abs(cosX);
if (visW < 0.4 || visH < 0.3) return;
/* Normale de surface & éclairage diffus */
var nx = sinY,
ny = -sinX * cosY,
nz = Math.abs(cosX * cosY);
var diff = nx * LX + ny * LY + nz * LZ; /* ∈ [-1, 1] */
/* Face visible (cosY ≥ 0 = face avant) */
var faceCol = cosY >= 0 ? p.col : p.back;
var litCol = applyLight(faceCol, diff);
/* Glint spéculaire : reflet blanc bref quand diff est élevé */
var glint = 0;
if (diff > 0.60) glint = Math.pow((diff - 0.60) / 0.40, 1.8) * 0.95;
var finalA = (p.amb ? p.alpha : Math.max(0, p.life)) * lAlpha;
if (finalA < 0.005) return;
var tilt = Math.atan2(p.vy, p.vx) * 0.25;
/* Motion-blur pour le layer proche — alpha fixe 0.22, indépendant de dt */
if (p.layer === 2 && !p.amb && LAYERS[2].blur > 0) {
ctx.save();
ctx.globalAlpha = 0.22;
ctx.fillStyle = litCol;
ctx.translate(p.x - p.vx * 2.2, p.y - p.vy * 2.2);
ctx.rotate(tilt);
ctx.fillRect(-visW / 2, -visH / 2, visW, visH);
ctx.restore();
}
ctx.save();
ctx.globalAlpha = finalA;
ctx.fillStyle = litCol;
ctx.translate(p.x, p.y);
ctx.rotate(tilt);
ctx.fillRect(-visW / 2, -visH / 2, visW, visH);
/* Overlay glint — durée 1.10 pour effet métallisé perceptible */
if (glint > 0.04) {
ctx.globalAlpha = finalA * glint * 1.10;
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(-visW / 2, -visH / 2, visW, visH);
}
ctx.restore();
}
/* Cercle */
function drCircle(ctx, p, lAlpha) {
var finalA = (p.amb ? p.alpha : Math.max(0, p.life)) * lAlpha;
if (finalA < 0.005) return;
ctx.save();
ctx.globalAlpha = finalA;
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, PI2);
ctx.fill();
ctx.restore();
}
/* Étoile 5 branches */
function drStar(ctx, p, lAlpha) {
var finalA = (p.amb ? p.alpha : Math.max(0, p.life)) * lAlpha;
if (finalA < 0.005) return;
var R = p.r,
r = p.r * 0.42;
ctx.save();
ctx.globalAlpha = finalA;
ctx.fillStyle = p.col;
ctx.translate(p.x, p.y);
ctx.rotate(p.theta);
ctx.beginPath();
for (var i = 0; i < 10; i++) {
var a = (i * PI / 5) - PI / 2,
rad = (i & 1) ? r : R;
if (i === 0) ctx.moveTo(Math.cos(a) * rad, Math.sin(a) * rad);
else ctx.lineTo(Math.cos(a) * rad, Math.sin(a) * rad);
}
ctx.closePath();
ctx.fill();
ctx.restore();
}
/* Ruban torsadé : largeur varie par segment (torsion visible) */
function drSerpentin(ctx, p, lAlpha) {
if (p.n < 2) return;
var finalA = (p.amb ? p.alpha : Math.max(0, p.life)) * lAlpha;
if (finalA < 0.005) return;
ctx.save();
ctx.lineCap = 'round';
for (var i = 1; i < p.n; i++) {
var twist = Math.cos(p.tck * p.twspd + i * 0.85 + p.ph);
var lw = p.lw * (0.35 + 0.65 * Math.abs(twist));
/* largeur plancher : le ruban torsadé ne se hache jamais */
/* Face vs dos selon signe de la torsion */
var fc = twist >= 0 ? p.col : p.back;
/* Éclairage simplifié basé sur angle de torsion */
var diff2 = twist * LZ + 0.28;
var litCol2 = applyLight(fc, clamp(diff2, -0.5, 0.82));
ctx.globalAlpha = finalA;
ctx.strokeStyle = litCol2;
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(p.segs[i - 1].x, p.segs[i - 1].y);
ctx.lineTo(p.segs[i].x, p.segs[i].y);
/* Ombre légère (critère 1) */
ctx.save();
ctx.globalAlpha = finalA * 0.18;
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
ctx.translate(0.8, 1.5);
ctx.stroke();
ctx.restore();
ctx.stroke();
}
ctx.restore();
}
/* Étincelle avec halo */
function drSpark(ctx, p) {
var a = Math.max(0, p.life);
if (a < 0.005) return;
ctx.save();
ctx.globalAlpha = a;
ctx.fillStyle = '#FFFDE7';
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, PI2);
ctx.fill();
/* Halo lumineux */
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 3.5);
g.addColorStop(0, 'rgba(255,253,200,' + (a * 0.55) + ')');
g.addColorStop(1, 'rgba(255,253,200,0)');
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 3.5, 0, PI2);
ctx.fill();
ctx.restore();
}
/* Éclair de bouche : glow radial en composite:lighter */
function drFlash(ctx, fl) {
var prog = fl.t / fl.maxT;
var alpha = (1 - prog) * 0.92,
radius = 16 + prog * 68;
var g = ctx.createRadialGradient(fl.x, fl.y, 0, fl.x, fl.y, radius);
g.addColorStop(0, 'rgba(255,248,190,' + alpha + ')');
g.addColorStop(0.3, 'rgba(255,210,80,' + (alpha * 0.62) + ')');
g.addColorStop(0.7, 'rgba(255,130,30,' + (alpha * 0.28) + ')');
g.addColorStop(1, 'rgba(255,100,20,0)');
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(fl.x, fl.y, radius, 0, PI2);
ctx.fill();
ctx.restore();
}
/* ── Sélecteur de couche (35 % lointain, 40 % milieu, 25 % proche) ───── */
function pickLayer() {
var r = Math.random();
return r < 0.35 ? 0 : r < 0.75 ? 1 : 2;
}
/* ── Scène ───────────────────────────────────────────────────────────────*/
function Scene(el) {
this.el = el;
this.parts = [];
this.flashes = []; /* éclairs de bouche */
this.queue = []; /* émission différée : {x,y,vx,vy,col,delay} */
this.qEl = 0; /* ms écoulées dans la queue courante */
this.running = false;
this.raf = null;
this.W = 480;
this.H = 300;
this._lastTs = null;
this._ambAcc = 0;
this.cv = el.querySelector('.ccn-canvas');
if (!this.cv) {
this.cv = document.createElement('canvas');
this.cv.className = 'ccn-canvas';
this.cv.setAttribute('aria-hidden', 'true');
el.insertBefore(this.cv, el.firstChild);
}
this.ctx = this.cv.getContext('2d');
var attr = el.getAttribute('data-colors');
this.colors = attr ? attr.split(',').map(function(c) {
return c.trim();
}) : COLORS_DEFAULT.slice();
this.resize();
var self = this;
if (window.ResizeObserver) new ResizeObserver(function() {
self.resize();
}).observe(el);
el.addEventListener('click', function(e) {
if (REDUCE) return;
if (e.target && e.target.classList && e.target.classList.contains('ccn-celebrate-btn')) return;
self.fireCannons({});
});
var btn = el.querySelector('.ccn-celebrate-btn');
if (btn) btn.addEventListener('click', function(e) {
e.stopPropagation();
if (!REDUCE) self.fireCannons({});
});
if (window.IntersectionObserver) {
var lastAuto = 0;
new IntersectionObserver(function(entries) {
if (!entries[0].isIntersecting) {
self.stopLoop();
return;
}
if (!self.running) self.startLoop();
var now = Date.now();
if (!REDUCE && now - lastAuto > 6000) {
lastAuto = now;
self.fireCannons({});
}
}, {
threshold: 0.35
}).observe(el);
}
}
Scene.prototype.resize = function() {
var dpr = window.devicePixelRatio || 1;
var w = this.el.offsetWidth || 480,
h = this.el.offsetHeight || 300;
var cw = Math.round(w * dpr),
ch = Math.round(h * dpr);
if (this.cv.width !== cw || this.cv.height !== ch) {
this.cv.width = cw;
this.cv.height = ch;
this.cv.style.width = w + 'px';
this.cv.style.height = h + 'px';
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this.W = w;
this.H = h;
};
Scene.prototype.spawnOne = function(x, y, vx, vy, col, layer) {
if (this.parts.length >= MAX_PARTS) return;
var r = Math.random();
if (r < 0.32) this.parts.push(mkRect(x, y, vx, vy, col, layer));
else if (r < 0.44) this.parts.push(mkSquare(x, y, vx, vy, col, layer));
else if (r < 0.60) this.parts.push(mkSerpentin(x, y, vx, vy, col, layer));
else if (r < 0.73) this.parts.push(mkCircle(x, y, vx, vy, col, layer));
else this.parts.push(mkStar(x, y, vx, vy, col, layer));
};
/* fire() : un tir depuis (x,y) — émission étalée + flash + étincelles */
Scene.prototype.fire = function(opts) {
opts = opts || {};
var x = opts.x !== undefined ? opts.x : this.W / 2;
var y = opts.y !== undefined ? opts.y : this.H;
var ang = opts.angle !== undefined ? opts.angle : -PI / 2;
var spd = opts.spread !== undefined ? opts.spread : PI / 3;
var cnt = opts.count || 60;
var col = opts.colors || this.colors;
var spf = opts.speed || 1;
var self = this;
/* Recul du canon (critère 3) : 5 frames de décalage visuel */
this._recoilFrames = 5;
this._recoilDx = Math.cos(ang) * 3;
this._recoilDy = Math.sin(ang) * 3;
/* Éclair de bouche */
this.flashes.push({
x: x,
y: y,
t: 0,
maxT: 210
});
/* Étincelles de bouche */
if (!REDUCE) {
for (var s = 0; s < 7; s++) {
if (this.parts.length < MAX_PARTS) this.parts.push(mkSpark(x, y, ang));
}
}
/* Émission : 60% immédiate + 40% étalée en 5 vagues (30ms chacune) */
var immediate = Math.ceil(cnt * 0.60);
var deferred = cnt - immediate;
var waveSz = Math.max(1, Math.ceil(deferred / 5));
for (var i = 0; i < immediate; i++) {
var a = ang + (Math.random() - 0.5) * spd;
var v = rnd(7, 14) * spf;
self.spawnOne(x, y, Math.cos(a) * v, Math.sin(a) * v, pick(col), pickLayer());
}
for (var w = 0; w < 5; w++) {
for (var j = 0; j < waveSz; j++) {
if (this.parts.length + this.queue.length >= MAX_PARTS) break;
var a2 = ang + (Math.random() - 0.5) * spd;
var v2 = rnd(7, 14) * spf;
this.queue.push({
x: x,
y: y,
vx: Math.cos(a2) * v2,
vy: Math.sin(a2) * v2,
col: col,
delay: (w + 1) * 30
});
}
}
this.qEl = 0;
if (!this.running) this.startLoop();
this.step(1); /* synchro canvas avant capture éventuelle */
};
Scene.prototype.fireCannons = function(opts) {
opts = opts || {};
var col = opts.colors || this.colors;
var cnt = opts.count || 140;
var spd = Math.max(1, this.H / 270);
this.fire({
x: 0,
y: this.H,
angle: -1.13,
spread: PI / 3,
count: (cnt / 2) | 0,
colors: col,
speed: spd
});
this.fire({
x: this.W,
y: this.H,
angle: -2.01,
spread: PI / 3,
count: (cnt / 2) | 0,
colors: col,
speed: spd
});
};
/* Traitement de la queue différée */
Scene.prototype.processQueue = function(dtMs) {
if (!this.queue.length) return;
this.qEl += dtMs;
var remaining = [];
for (var i = 0; i < this.queue.length; i++) {
var it = this.queue[i];
if (it.delay <= this.qEl) this.spawnOne(it.x, it.y, it.vx, it.vy, pick(it.col), pickLayer());
else remaining.push(it);
}
this.queue = remaining;
};
Scene.prototype.startLoop = function() {
if (this.running) return;
this.running = true;
this._lastTs = null;
var lbl = this.el.querySelector('.ccn-label');
if (lbl) lbl.style.opacity = '0';
var self = this;
function tick(ts) {
if (!self.running) return;
var dt = self._lastTs ? Math.min((ts - self._lastTs) / 16.67, 3) : 1;
self._lastTs = ts;
self.drizzle(dt);
self.processQueue(dt * 16.67);
self.step(dt);
/* Restaurer le label quand l'écran est vide (correction juge) */
if (!self.parts.length && !self.queue.length && lbl) lbl.style.opacity = '1';
if (self.parts.length || self.flashes.length || self.queue.length || !REDUCE)
self.raf = requestAnimationFrame(tick);
else {
self.running = false;
self.ctx.clearRect(0, 0, self.W, self.H);
var l = self.el.querySelector('.ccn-label');
if (l) l.style.opacity = '1';
}
}
this.raf = requestAnimationFrame(tick);
};
Scene.prototype.stopLoop = function() {
this.running = false;
this._ambAcc = 0;
if (this.raf) {
cancelAnimationFrame(this.raf);
this.raf = null;
}
};
/* Mise à jour + rendu d'un frame */
Scene.prototype.step = function(dt) {
dt = dt || 1;
var ctx = this.ctx,
W = this.W,
H = this.H;
ctx.clearRect(0, 0, W, H);
/* Recul du canon : décalage du repère de rendu sur 5 frames */
var hasRecoil = (this._recoilFrames || 0) > 0;
if (hasRecoil) {
ctx.save();
ctx.translate(this._recoilDx || 0, this._recoilDy || 0);
this._recoilFrames--;
}
/* 1. Éclairs de bouche (fond) */
var aliveF = [];
for (var f = 0; f < this.flashes.length; f++) {
var fl = this.flashes[f];
drFlash(ctx, fl);
fl.t += dt * 16.67;
if (fl.t < fl.maxT) aliveF.push(fl);
}
this.flashes = aliveF;
/* 2. Update toutes les particules → alive */
var alive = [];
for (var i = 0; i < this.parts.length; i++) {
var p = this.parts[i],
dead = false;
if (p.t === 'r' || p.t === 'sq') {
upRect(p, dt);
if (p.amb) {
if (p.y > H + 10) resetAmb(p, W);
} else dead = p.life <= 0.01 || p.y > H + 70;
} else if (p.t === 's') {
upSerpentin(p, dt);
if (p.amb) {
if (p.segs[0].y > H + 10) resetAmbSerpentin(p, W);
} else dead = p.life <= 0.01 || p.segs[0].y > H + 90;
} else if (p.t === 'c') {
upCircle(p, dt);
if (p.amb) {
if (p.y > H + 10) resetAmbC(p, W);
} else dead = p.life <= 0.01 || p.y > H + 60;
} else if (p.t === 'star') {
upStar(p, dt);
if (p.amb) {
if (p.y > H + 10) resetAmbStar(p, W);
} else dead = p.life <= 0.01 || p.y > H + 60;
} else if (p.t === 'spark') {
upSpark(p, dt);
dead = p.life <= 0.01 || p.y > H + 30;
}
if (!dead) alive.push(p);
}
this.parts = alive;
/* 3. Rendu en 3 passes (far→mid→near) pour la parallaxe */
for (var pass = 0; pass < 3; pass++) {
for (var i = 0; i < this.parts.length; i++) {
var p = this.parts[i];
var rl = p.amb ? 1 : p.layer;
/* Étincelles et sparks toujours au pass 2 */
if (p.t === 'spark') {
if (pass === 2) drSpark(ctx, p);
continue;
}
if (rl !== pass) continue;
var la = p.amb ? 1 : LAYERS[p.layer].alpha;
if (p.t === 'r') drRect(ctx, p, la);
else if (p.t === 's') drSerpentin(ctx, p, la);
else if (p.t === 'c') drCircle(ctx, p, la);
else if (p.t === 'star') drStar(ctx, p, la);
}
}
if (hasRecoil) ctx.restore();
};
/* Bruine ambiante : 1 particule toutes ~230ms, max 55 ambiantes */
Scene.prototype.drizzle = function(dt) {
if (REDUCE) return;
this._ambAcc = (this._ambAcc || 0) + dt;
if (this._ambAcc < 14) return;
this._ambAcc -= 14;
var ambN = 0;
for (var i = 0; i < this.parts.length; i++)
if (this.parts[i].amb) ambN++;
if (ambN >= 55 || this.parts.length >= MAX_PARTS) return;
var r = Math.random(),
c = pick(this.colors);
if (r < 0.40) this.parts.push(mkAmbRect(this.W, c));
else if (r < 0.65) this.parts.push(mkAmbCircle(this.W, c));
else if (r < 0.85) this.parts.push(mkAmbStar(this.W, c));
else this.parts.push(mkAmbSerpentin(this.W, c));
};
/* ── Init ────────────────────────────────────────────────────────────────*/
var _scenes = [];
function init() {
document.querySelectorAll('.ccn-scene').forEach(function(el) {
var s = new Scene(el);
_scenes.push(s);
if (REDUCE) {
s.fire({
x: s.W / 2,
y: s.H,
angle: -PI / 2,
spread: PI * 0.55,
count: 20,
speed: 0.70
});
} else {
s.fireCannons({});
}
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
/* ── API publique ────────────────────────────────────────────────────────*/
window.ConfettiCannon = {
fire: function(opts) {
_scenes.forEach(function(s) {
s.fire(opts || {});
});
},
fireCannons: function(opts) {
_scenes.forEach(function(s) {
s.fireCannons(opts || {});
});
},
fireScene: function(idx, opts) {
if (_scenes[idx]) _scenes[idx].fireCannons(opts || {});
},
scenes: _scenes
};
}());
})();Balloon Release
Un canvas 2D unique rend des ballons semi-transparents (dégradé radial décalé, reflets spéculaires, liseré lumineux) avec une ficelle Verlet souple tracée en courbe lisse. Les ballons qui se chevauchent se teintent. Le pointeur les repousse doucement ; un clic fait éclater le ballon visé (fragments + flash). En mode accessibilité réduite : ballons statiques dessinés une seule fois.
💡 Idéal pour : bannières anniversaire SaaS (« 1 an avec nous »), pages de confirmation d'abonnement, messages de bienvenue, célébrations de jalons produit.
Page de l’effet →Success Celebration
Composant de confirmation orchestré : cercle SVG tracé progressivement depuis 12 h, coche dessinée, deux halos pulsants, burst de particules en anneau (Canvas 2D) et texte en stagger. Rejouable ; en mode réduit, coche et texte apparaissent instantanément sans aucune particule.
💡 Idéal pour : confirmation de paiement, sauvegarde de formulaire, succès d'onboarding SaaS, validation de commande ou fin de tutoriel interactif.
Page de l’effet →Reaction Burst
Barre de réactions façon live streaming : chaque clic envoie l'emoji en trajectoire bézier
avec wobble sinus et rotation. Un flux simulé fait apparaître en continu des réactions
d'autres spectateurs depuis le flanc droit. Pool recyclé (max 40 particules), compteurs
animés. En mode prefers-reduced-motion : compteurs seuls, fondu sans trajectoire.
💡 Idéal pour : lecteur vidéo live, webinaire interactif, page de lancement produit, galerie de commentaires en temps réel, app communautaire.
Page de l’effet →Sparkler Light Painting
Canvas 2D à deux couches : un buffer d'accumulation estompé chaque frame (destination-out α faible) crée la traînée lumineuse persistante façon pose longue. Le pointeur devient un cierge magique — halo orangé incandescent + étincelles jaillissantes à gravité légère et crépitement. Sans interaction, une main invisible trace un cœur paramétrique en boucle. Composition lighter pour l'accumulation additive ; sprites radiaux pré-renderés (pas de shadowBlur). Repli statique sans particules si mouvement réduit.
💡 Idéal pour : hero de lancement produit en thème sombre, page de vœux de fin d'année, section célébration premium, landing page événementielle ou anniversaire client SaaS.
Page de l’effet →