Skip to content
JavaScript js canvas 7 min read

Building a Particle System

A particle system is the workhorse behind nearly every flashy visual effect you see on the web: fireworks, falling snow, smoke trails, sparks, confetti, and explosions. The idea is simple but powerful — you spawn many tiny, independent objects (“particles”), give each one a position, velocity, and lifespan, then update and draw all of them every frame. By tuning a handful of numbers, the same code can produce wildly different effects. This page builds a small, reusable particle system from scratch on the 2D canvas.

How a particle system works

Every particle system has three moving parts:

  1. The particle — a lightweight object holding state: position, velocity, age, and appearance (color, size, opacity).
  2. The emitter — code that spawns new particles, usually at a point, with randomized velocities so the burst looks organic.
  3. The update/render loop — each frame, advance every particle’s physics, discard the dead ones, and draw the survivors.

The whole thing runs inside a requestAnimationFrame loop. The art is in the randomness: small variations in angle, speed, size, and lifetime are what turn a mechanical grid of dots into something that feels alive.

The Particle class

A particle needs to know where it is, where it’s heading, and how much life it has left. We model life as a value that starts at 1 and decays toward 0; we reuse that same value for opacity so particles fade out naturally as they die.

class Particle {
  constructor(x, y, options = {}) {
    this.x = x;
    this.y = y;

    // Random direction and speed for an organic burst.
    const angle = Math.random() * Math.PI * 2;
    const speed = options.speed ?? Math.random() * 4 + 1;
    this.vx = Math.cos(angle) * speed;
    this.vy = Math.sin(angle) * speed;

    this.gravity = options.gravity ?? 0.05;
    this.friction = options.friction ?? 0.99;
    this.size = options.size ?? Math.random() * 3 + 1;
    this.color = options.color ?? `hsl(${Math.random() * 360}, 100%, 60%)`;

    this.life = 1;                       // 1 = fully alive, 0 = dead
    this.decay = options.decay ?? 0.015; // life lost per frame
  }

  get isDead() {
    return this.life <= 0;
  }

  update() {
    this.vx *= this.friction;
    this.vy *= this.friction;
    this.vy += this.gravity; // pull downward
    this.x += this.vx;
    this.y += this.vy;
    this.life -= this.decay;
  }

  draw(ctx) {
    ctx.save();
    ctx.globalAlpha = Math.max(this.life, 0);
    ctx.fillStyle = this.color;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }
}

Tip: Using globalAlpha = this.life is the cheapest possible fade. For glowing sparks, multiply this.size by this.life too, so particles shrink as they vanish.

The emitter

The emitter owns the array of live particles. Its two jobs are spawning a batch and advancing the simulation. We update and draw in a single pass, and we remove dead particles by filtering the array each frame.

class ParticleSystem {
  constructor() {
    this.particles = [];
  }

  emit(x, y, count = 60, options = {}) {
    for (let i = 0; i < count; i++) {
      this.particles.push(new Particle(x, y, options));
    }
  }

  update() {
    // Update first, then keep only the survivors.
    for (const p of this.particles) p.update();
    this.particles = this.particles.filter((p) => !p.isDead);
  }

  render(ctx) {
    for (const p of this.particles) p.draw(ctx);
  }

  get count() {
    return this.particles.length;
  }
}

Removing dead particles is essential — without it, the array grows forever and the frame rate collapses. filter is clean and fast enough for thousands of particles; for tens of thousands, prefer an in-place swap-and-pop or a fixed-size object pool (see Best Practices).

Wiring it into an animation loop

The loop clears the canvas, updates the system, renders it, and schedules the next frame. Here we trigger a fresh burst wherever the user clicks.

const canvas = document.querySelector("#stage");
const ctx = canvas.getContext("2d");
const system = new ParticleSystem();

canvas.addEventListener("click", (e) => {
  const rect = canvas.getBoundingClientRect();
  system.emit(e.clientX - rect.left, e.clientY - rect.top, 80);
});

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  system.update();
  system.render(ctx);
  requestAnimationFrame(loop);
}
loop();

If you want glowing trails instead of crisp circles, replace clearRect with a translucent fill that lets old frames linger:

ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
ctx.fillRect(0, 0, canvas.width, canvas.height);

Output:

After a click: ~80 colored dots burst outward, arc under gravity,
slow from friction, fade to transparent, then get filtered out.
Live particle count rises to ~80 and decays back to 0 over ~1 second.

Interactive demo: click for fireworks

Click anywhere in the pen below to launch a firework. Each burst spawns particles in every direction with randomized color and speed, and the faint background fill creates motion trails.

<canvas id="stage" width="600" height="380" style="background:#0b0b16;display:block"></canvas>
<p style="font-family:sans-serif;color:#aaa">Click the canvas to launch fireworks.</p>
<script>
const canvas = document.getElementById("stage");
const ctx = canvas.getContext("2d");

class Particle {
  constructor(x, y) {
    this.x = x; this.y = y;
    const angle = Math.random() * Math.PI * 2;
    const speed = Math.random() * 5 + 1;
    this.vx = Math.cos(angle) * speed;
    this.vy = Math.sin(angle) * speed;
    this.size = Math.random() * 3 + 1;
    this.color = `hsl(${Math.random() * 360}, 100%, 60%)`;
    this.life = 1;
  }
  get isDead() { return this.life <= 0; }
  update() {
    this.vx *= 0.98; this.vy *= 0.98;
    this.vy += 0.06;
    this.x += this.vx; this.y += this.vy;
    this.life -= 0.012;
  }
  draw() {
    ctx.globalAlpha = Math.max(this.life, 0);
    ctx.fillStyle = this.color;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalAlpha = 1;
  }
}

let particles = [];
canvas.addEventListener("click", (e) => {
  const r = canvas.getBoundingClientRect();
  const x = e.clientX - r.left, y = e.clientY - r.top;
  for (let i = 0; i < 90; i++) particles.push(new Particle(x, y));
});

function loop() {
  ctx.fillStyle = "rgba(11,11,22,0.18)";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  for (const p of particles) p.update();
  particles = particles.filter((p) => !p.isDead);
  for (const p of particles) p.draw();
  requestAnimationFrame(loop);
}
loop();
</script>

A second effect: falling snow

The same architecture produces gentle snow with only a few tweaks. Snow has no explosive burst — particles spawn continuously across the top, drift sideways, and wrap back to the top when they leave the bottom, so the field never empties.

<canvas id="snow" width="600" height="380" style="background:#1a2238;display:block"></canvas>
<script>
const canvas = document.getElementById("snow");
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;

const flakes = Array.from({ length: 180 }, () => ({
  x: Math.random() * W,
  y: Math.random() * H,
  r: Math.random() * 2.5 + 0.5,
  sway: Math.random() * 2 * Math.PI,
  speed: Math.random() * 1 + 0.4,
}));

function loop() {
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = "rgba(255,255,255,0.85)";
  for (const f of flakes) {
    f.sway += 0.01;
    f.x += Math.sin(f.sway) * 0.6;
    f.y += f.speed;
    if (f.y > H) { f.y = -f.r; f.x = Math.random() * W; }
    ctx.beginPath();
    ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
    ctx.fill();
  }
  requestAnimationFrame(loop);
}
loop();
</script>

Tuning the look

These few parameters cover most effects you’ll want to build:

ParameterEffectFireworksSnowSmoke
gravityVertical pull per frame0.050 (use steady fall)-0.02 (rises)
frictionVelocity damping0.9810.96
decayLife lost per frame0.0120 (recycle)0.01
Spawn angleDirection spread0–2πdownward onlyupward cone
ColorAppearancerandom huewhitegrey, low alpha

Warning: Particle counts multiply fast. A 60-particle burst on every click of a fast trigger can reach thousands of live objects in seconds. Cap your total (e.g. skip emitting when system.count > 5000) to protect the frame rate.

Best Practices

  • Always remove dead particles. Filtering or swap-and-pop each frame keeps the array bounded; forgetting this is the number-one cause of particle-system slowdown.
  • Pool objects for heavy systems. Reuse Particle instances instead of allocating new ones every emit — this avoids garbage-collection pauses that cause stutter.
  • Batch by appearance. Group particles that share a color or fillStyle and draw them together to minimize state changes on the context.
  • Prefer cheap shapes. Filled arc calls are fine for hundreds of particles; for thousands, draw squares with fillRect or use a pre-rendered sprite via drawImage.
  • Make life double as alpha. Tying globalAlpha (and optionally size) to life gives free, smooth fade-outs with no extra state.
  • Keep physics frame-rate aware. For consistent motion across devices, scale velocity by delta time rather than assuming a fixed 60 fps.
  • Cap the maximum particle count. A hard ceiling guarantees predictable performance no matter how aggressively the emitter fires.
Last updated June 1, 2026
Was this helpful?