---
name: canvas-particle-explosion
description: Use when you want "Playful, dramatic, interactive" - Explodes particles outward from a click, logo, or element.
---

# Canvas Particle Explosion

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Playful, dramatic, interactive
>
> **Best use:** Celebrations, gaming, interactions
>
> **Ratings:** Professional 2/5 - Casual 5/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

A burst of short-lived particles fired outward from a point — a click, a tap, a button, an unlocked achievement. You spawn a few dozen particles with random outward velocities, then every animation frame you integrate cheap physics (gravity pulls them down, drag slows them, their alpha decays) and redraw them on a `<canvas>`. This demo mixes two flavours: **tumbling confetti ribbons** (rectangles that flip in 3D) and **glowing sparks** (additive dots), so a single "pop" reads as celebratory rather than mechanical. Clicking anywhere fires a pop at the pointer; the **Celebrate** button fires a staggered multi-volley "confetti cannon". It is the go-to moment-maker for wins, level-ups, reward claims and playful interactions.

## Dependencies / CDN

**None — pure vanilla JS + the 2D canvas API.** No library, no WebGL.

The demo only loads two display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Hanken+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
```

## HTML

A positioned stage, a full-bleed canvas on top (click-through), and your real content underneath:

```html
<div class="stage" id="stage">
  <canvas class="fx-canvas" id="canvas" aria-hidden="true"></canvas>
  <div class="card">
    <h2>7-day streak, smashed.</h2>
    <button id="celebrate" type="button">🎉 Celebrate</button>
  </div>
</div>
```

## CSS

The only lines that matter for the effect are the stage/canvas positioning — the canvas covers the stage, sits on top, and is `pointer-events:none` so the UI underneath stays clickable:

```css
.stage{ position:relative; overflow:hidden; border-radius:28px; min-height:600px;
        display:flex; align-items:center; justify-content:center; isolation:isolate;
        cursor:crosshair; background:radial-gradient(125% 120% at 50% -12%,#2c1254,#110626); }

.fx-canvas{ position:absolute; inset:0; z-index:2; width:100%; height:100%;
            pointer-events:none; }                 /* confetti flies OVER the card, clicks pass through */

.card{ position:relative; z-index:1; }             /* content sits under the canvas */
```

## JavaScript

The whole engine — spawn, integrate, fade, two-pass render, and a reduced-motion fallback:

```js
(function(){
  var stage=document.getElementById('stage'), canvas=document.getElementById('canvas');
  var ctx=canvas.getContext('2d'), TAU=Math.PI*2;
  var reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
  var confetti=[], sparks=[], W=0,H=0,dpr=1, running=false, last=0;
  var COL=['#ffd166','#ff5d8f','#48d6ff','#5be7a9','#b794ff','#ff9f43'];
  var rand=function(a,b){return a+Math.random()*(b-a)}, pick=function(a){return a[(Math.random()*a.length)|0]};

  function resize(){                                  // DPR-aware: backing store != CSS size
    var r=stage.getBoundingClientRect(); W=r.width; H=r.height;
    dpr=Math.min(devicePixelRatio||1,2);              // cap at 2 for perf
    canvas.width=W*dpr; canvas.height=H*dpr;
    canvas.style.width=W+'px'; canvas.style.height=H+'px';
    ctx.setTransform(dpr,0,0,dpr,0,0);                // now draw in CSS pixels
    if(reduced) staticBurst(W/2,H*0.42);
  }

  function addConfetti(x,y,a,s){
    if(confetti.length>=600) confetti.shift();        // cap memory
    var life=rand(1.5,2.6);
    confetti.push({x:x,y:y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,grav:rand(560,720),drag:rand(.84,.90),
      w:rand(6,11),h:rand(9,16),color:pick(COL),rot:rand(0,TAU),rotV:rand(-9,9),
      flip:rand(0,TAU),flipV:rand(4,9),life:life,maxLife:life});
  }
  function addSpark(x,y,a,s){
    if(sparks.length>=320) sparks.shift();
    var life=rand(.45,.95);
    sparks.push({x:x,y:y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,grav:rand(120,260),drag:rand(.62,.78),
      r:rand(1.4,3.2),color:pick(COL),life:life,maxLife:life});
  }
  function pop(x,y,k){ k=k||1;                         // omnidirectional click/tap burst
    for(var i=0;i<28*k;i++) addConfetti(x,y,rand(0,TAU),rand(160,460)*k);
    for(var j=0;j<11*k;j++) addSpark(x,y,rand(0,TAU),rand(220,560)*k);
    ensureLoop();
  }
  function cannon(x,y){ var up=-Math.PI/2;             // upward confetti cannon
    for(var i=0;i<56;i++) addConfetti(x,y,up+rand(-.6,.6),rand(620,1020));
    for(var j=0;j<17;j++) addSpark(x,y,up+rand(-.7,.7),rand(520,940));
  }
  function celebrate(){                                // big multi-burst (Celebrate button)
    if(reduced) return staticBurst(W/2,H*0.42,true);
    var volley=function(){ cannon(W*0.18,H); cannon(W*0.82,H); cannon(W/2,H*1.03); ensureLoop(); };
    volley(); pop(W/2,H*0.42,1.2);
    setTimeout(volley,170); setTimeout(volley,360);    // staggered shower
  }

  function fade(p){ var age=p.maxLife-p.life;
    return Math.max(0, Math.min(Math.min(1,age/0.06), Math.min(1,p.life/(p.maxLife*0.42)))); }

  function step(dt){
    ctx.clearRect(0,0,W,H);
    for(var i=confetti.length-1;i>=0;i--){ var p=confetti[i]; if((p.life-=dt)<=0){confetti.splice(i,1);continue;}
      var d=Math.pow(p.drag,dt); p.vx*=d; p.vy*=d; p.vy+=p.grav*dt;
      p.x+=p.vx*dt; p.y+=p.vy*dt; p.rot+=p.rotV*dt; p.flip+=p.flipV*dt; }
    for(i=sparks.length-1;i>=0;i--){ var s=sparks[i]; if((s.life-=dt)<=0){sparks.splice(i,1);continue;}
      var ds=Math.pow(s.drag,dt); s.vx*=ds; s.vy*=ds; s.vy+=s.grav*dt; s.x+=s.vx*dt; s.y+=s.vy*dt; }
    // pass 1 - confetti ribbons (normal blend; scale(1,cos(flip)) fakes a 3D tumble)
    for(i=0;i<confetti.length;i++){ p=confetti[i]; var a=fade(p); if(a<=0)continue;
      ctx.save(); ctx.globalAlpha=a; ctx.translate(p.x,p.y); ctx.rotate(p.rot);
      ctx.scale(1,Math.cos(p.flip)); ctx.fillStyle=p.color; ctx.fillRect(-p.w/2,-p.h/2,p.w,p.h); ctx.restore(); }
    // pass 2 - sparks (additive glow)
    ctx.globalCompositeOperation='lighter';
    for(i=0;i<sparks.length;i++){ s=sparks[i]; var sa=fade(s); if(sa<=0)continue; ctx.fillStyle=s.color;
      ctx.globalAlpha=sa*0.45; ctx.beginPath(); ctx.arc(s.x,s.y,s.r*2.4,0,TAU); ctx.fill();
      ctx.globalAlpha=sa;      ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,TAU); ctx.fill(); }
    ctx.globalCompositeOperation='source-over'; ctx.globalAlpha=1;
  }

  function loop(t){ if(!running)return; var dt=last?Math.min((t-last)/1000,1/30):1/60; last=t; step(dt);
    if(confetti.length||sparks.length) requestAnimationFrame(loop);
    else { running=false; last=0; ctx.clearRect(0,0,W,H); } }            // stop when idle
  function ensureLoop(){ if(!running){ running=true; last=0; requestAnimationFrame(loop); } }

  function staticBurst(x,y,big){                       // reduced-motion: one calm spray, no loop
    ctx.clearRect(0,0,W,H);
    (big?[46,86,122]:[40,74]).forEach(function(rad,k){ var n=10+k*6;
      for(var i=0;i<n;i++){ var a=(i/n)*TAU+k*0.3; ctx.globalAlpha=0.55-k*0.12; ctx.fillStyle=COL[i%COL.length];
        ctx.beginPath(); ctx.arc(x+Math.cos(a)*rad,y+Math.sin(a)*rad,3.2,0,TAU); ctx.fill(); } });
    ctx.globalAlpha=1;
  }

  stage.addEventListener('pointerdown',function(e){ var r=canvas.getBoundingClientRect(), x=e.clientX-r.left, y=e.clientY-r.top;
    reduced ? staticBurst(x,y) : pop(x,y,1); });
  var btn=document.getElementById('celebrate');
  btn.addEventListener('pointerdown',function(e){ e.stopPropagation(); });  // don't also fire the generic pop
  btn.addEventListener('click',celebrate);

  if('ResizeObserver' in window) new ResizeObserver(resize).observe(stage); else addEventListener('resize',resize);
  resize();
})();
```

## How it works

- **Spawn:** each particle gets a random outward angle + speed, converted to a velocity vector (`vx=cos(a)*s`, `vy=sin(a)*s`). A click uses the full `0..2π`; the *cannon* biases the angle straight up (`-π/2 ± 0.6`) so confetti arcs upward and then rains back down.
- **Integrate (per frame, `dt` in seconds):** `vy += gravity·dt` (fall), `v *= drag^dt` (air resistance — frame-rate independent), then `pos += v·dt`. Confetti also spins (`rot`) and flips (`flip`).
- **Fade:** every particle carries `life`/`maxLife`; `fade()` ramps alpha up in the first 60 ms and down over the last 42 % of life, so nothing pops in or vanishes abruptly.
- **Two-pass render:** confetti is drawn with the normal blend mode, each as a `fillRect` that's `translate`/`rotate`/`scale(1, cos(flip))`-d — the vertical scale collapsing through zero fakes a ribbon turning edge-on in 3D. Sparks are drawn in a second pass with `globalCompositeOperation='lighter'` (additive) plus a faint larger disc behind each for a glow. Batching by blend mode avoids switching state per particle.
- **The loop stops itself** when both arrays empty (`running=false`), so an idle stage burns zero CPU; the next interaction calls `ensureLoop()` to restart it.

## Customization

- **Density / drama:** change the `28`/`11` counts in `pop()` and `56`/`17` in `cannon()`, or the `scale` argument.
- **Physics feel:** higher `grav` = snappier fall; lower `drag` (→ ~0.6) = faster decay/shorter throw; raise initial speed for a wider blast radius.
- **Palette:** edit the `COL` array — keep 5-7 saturated hues for a festive read, or drop to two brand colours for a restrained "spark" accent.
- **Shapes:** swap `fillRect` for `arc()` (round dots), `roundRect`, a star path, or `drawImage()` of a tiny sprite/logo for branded confetti.
- **Trigger:** call `pop(x,y)` from any event — a form-success handler, a `success` websocket message, an element's `getBoundingClientRect()` centre (explode *from a logo*).
- **Lifetime / volleys:** tweak the `life` ranges, or add more `setTimeout` volleys in `celebrate()` for a longer shower.

## Accessibility & performance

- **Reduced motion:** gated up-front with `matchMedia('(prefers-reduced-motion: reduce)')`. When set, no animation loop runs at all — a single calm, static spray is drawn once per interaction (`staticBurst`). Always check this in JS, not just CSS, before starting a `requestAnimationFrame` loop.
- **Decorative only:** the canvas is `aria-hidden="true"` and the real content (heading, stats, buttons) is plain DOM, fully readable and operable with the effect disabled or failed.
- **DPR cap:** the backing store is sized `W*dpr × H*dpr` but `devicePixelRatio` is clamped to 2, so retina/3x phones stay crisp without quadrupling the fill cost.
- **Bounded work:** hard caps (`600` confetti / `320` sparks) drop the oldest particle when exceeded, so mashing the stage can't grow memory unbounded; the loop self-suspends when idle; `clearRect` + two-pass batching keep per-frame state changes minimal.

## Gotchas

- **Clear every frame** (`clearRect`) or particles smear into trails — unless you *want* trails, in which case paint a low-alpha rect instead.
- **DPR is the classic bug:** you must size the bitmap (`canvas.width`) separately from the CSS box (`canvas.style.width`) and `setTransform(dpr,…)`; setting only one gives a blurry or mis-scaled canvas.
- **Reset the blend mode.** After the additive spark pass, set `globalCompositeOperation='source-over'` and `globalAlpha=1` or the next frame's confetti renders wrong.
- **Pointer → canvas coords** must go through `getBoundingClientRect()`; raw `clientX/Y` are viewport-relative and will be offset.
- **`stopPropagation()` on the Celebrate button's `pointerdown`**, or the stage's generic "click anywhere" handler fires a second small pop on top of the big volley.
- **Clamp `dt`** (here `Math.min(dt,1/30)`): when the tab is backgrounded then refocused, the first frame's delta is huge and particles teleport off-screen without the clamp.
- `pointerdown` (not `click`) is used so the burst feels instant and works for touch/pen; `click` is kept only for the button's deliberate action.
</content>
</invoke>
