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:
- The particle — a lightweight object holding state: position, velocity, age, and appearance (color, size, opacity).
- The emitter — code that spawns new particles, usually at a point, with randomized velocities so the burst looks organic.
- 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.lifeis the cheapest possible fade. For glowing sparks, multiplythis.sizebythis.lifetoo, 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:
| Parameter | Effect | Fireworks | Snow | Smoke |
|---|---|---|---|---|
gravity | Vertical pull per frame | 0.05 | 0 (use steady fall) | -0.02 (rises) |
friction | Velocity damping | 0.98 | 1 | 0.96 |
decay | Life lost per frame | 0.012 | 0 (recycle) | 0.01 |
| Spawn angle | Direction spread | 0–2π | downward only | upward cone |
| Color | Appearance | random hue | white | grey, 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
Particleinstances 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
fillStyleand draw them together to minimize state changes on the context. - Prefer cheap shapes. Filled
arccalls are fine for hundreds of particles; for thousands, draw squares withfillRector use a pre-rendered sprite viadrawImage. - Make life double as alpha. Tying
globalAlpha(and optionally size) tolifegives 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.