Make a particle effect in HTML with canvas and JavaScript

A particle effect is an array of small objects, a loop that moves them, and a canvas that draws them. A few dozen lines of JavaScript, no library.

A particle effect in HTML is a <canvas> and a loop. Each particle is a small object with a position, a velocity and an age. Every frame the loop moves each one a little, draws it, and throws it away when it gets too old.

Try it. Drag on the dark area to move the fountain, and change the spawn rate and gravity below it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Particle fountain</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; }
  canvas { display: block; width: 100%; height: 300px; touch-action: none; cursor: crosshair; }
  .controls { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; padding: 10px 14px; font-size: 14px; background: #111827; }
  label { display: flex; align-items: center; gap: 6px; }
  input[type=range] { width: 110px; }
  output { min-width: 3ch; font-variant-numeric: tabular-nums; color: #fbbf24; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="controls">
  <label>Per second <input id="rate" type="range" min="10" max="600" value="150"><output id="rateOut">150</output></label>
  <label>Gravity <input id="grav" type="range" min="0" max="1200" value="500"><output id="gravOut">500</output></label>
  <span>Alive: <output id="alive">0</output></span>
</div>

<script>
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  const rate = document.getElementById('rate'), grav = document.getElementById('grav');
  const particles = [];
  const emitter = { x: 0, y: 0 };

  // Match the drawing buffer to the CSS size times devicePixelRatio (sharp on phones)
  function resize() {
    const dpr = window.devicePixelRatio || 1;
    canvas.width = Math.round(canvas.clientWidth * dpr);
    canvas.height = Math.round(canvas.clientHeight * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);  // draw in CSS pixels
    emitter.x = canvas.clientWidth / 2;
    emitter.y = canvas.clientHeight - 20;
  }
  new ResizeObserver(resize).observe(canvas);

  // Press or drag on the canvas to move the emitter
  canvas.addEventListener('pointermove', (e) => {
    if (e.buttons || e.pointerType === 'touch') { emitter.x = e.offsetX; emitter.y = e.offsetY; }
  });
  canvas.addEventListener('pointerdown', (e) => { emitter.x = e.offsetX; emitter.y = e.offsetY; });

  function spawn() {
    const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.6;  // mostly upward
    const speed = 250 + Math.random() * 150;                   // CSS px per second
    particles.push({
      x: emitter.x, y: emitter.y,
      vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
      life: 0, maxLife: 1.2 + Math.random() * 0.8,             // seconds
      size: 2 + Math.random() * 3,
      hue: 30 + Math.random() * 30
    });
  }

  let last = performance.now(), debt = 0;
  function frame(now) {
    const dt = Math.min((now - last) / 1000, 0.05);  // seconds, capped after a long pause
    last = now;

    // Spawn by time, not by frame, so 60 Hz and 120 Hz screens look the same
    debt += rate.value * dt;
    while (debt >= 1) { spawn(); debt--; }

    ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.life += dt;
      if (p.life >= p.maxLife) { particles.splice(i, 1); continue; }  // remove dead particles
      p.vy += grav.value * dt;   // gravity changes velocity
      p.x += p.vx * dt;          // velocity changes position
      p.y += p.vy * dt;
      ctx.globalAlpha = 1 - p.life / p.maxLife;  // fade out over its life
      ctx.fillStyle = `hsl(${p.hue} 95% 60%)`;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;

    document.getElementById('alive').value = particles.length;
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  rate.addEventListener('input', () => document.getElementById('rateOut').value = rate.value);
  grav.addEventListener('input', () => document.getElementById('gravOut').value = grav.value);
</script>
</body>
</html>
Particles spawn from a point, fall under gravity and fade out. Drag on the canvas to move the source.

There is no library in that example. It uses the 2D canvas context, requestAnimationFrame and plain arrays. The rest of this guide goes through its parts, then adds lines between particles and a confetti burst.

What a particle is

A particle is only data. The canvas does not remember it; it just holds pixels. So each particle lives in an array as an ordinary object:

particles.push({
  x: 200, y: 280,          // position, CSS px
  vx: 40, vy: -320,        // velocity, px per second
  life: 0, maxLife: 1.5,   // age and lifespan, seconds
  size: 3, hue: 40         // how it looks
});
One particle's life: spawned into the array, moved and faded every frame, removed when its time is up.
One particle's life: spawned into the array, moved and faded every frame, removed when its time is up.

Spawning can happen at a fixed point, at the pointer, or at an element on the page. The fountain uses a point that follows the pointer while you press. The confetti below uses the centre of a button.

The loop: requestAnimationFrame and delta time

requestAnimationFrame calls your function before the next repaint, and passes it a timestamp in milliseconds. It usually runs at the display's refresh rate, so a 120 Hz screen gets twice as many calls as a 60 Hz one.

That is why every speed in the examples is per second, not per frame. Each frame works out how much time has passed and scales the movement by it:

let last = performance.now();
function frame(now) {
  const dt = Math.min((now - last) / 1000, 0.05);  // seconds, capped
  last = now;
  for (const p of particles) {
    p.x += p.vx * dt;
    p.y += p.vy * dt;
  }
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

The cap stops a long pause, such as a background tab, from turning into one giant jump. requestAnimationFrame in JavaScript covers the loop itself in more depth, including pausing and fps counters.

Gravity, friction and fading

Forces change velocity, and velocity changes position. Gravity is a constant added to vy every second. Friction, or air drag, shrinks both velocity parts a little:

p.vy += gravity * dt;              // e.g. gravity = 500 px/s²
p.vx *= Math.pow(0.35, dt);        // keeps 35% of its speed per second
p.vy *= Math.pow(0.35, dt);
ctx.globalAlpha = 1 - p.life / p.maxLife;   // 1 when born, 0 when it dies

A factor such as p.vx *= 0.98 per frame looks similar, but it slows particles faster on a faster screen. Raising a factor to the power dt gives the same slowdown per second at any refresh rate.

Fading with globalAlpha is the cheapest way to make a particle disappear gently. Reset it to 1 after the loop, because it stays set for every later drawing call.

Removing dead particles

If the loop only adds particles, the array grows forever. Finished particles become invisible, but they are still updated every frame and still hold memory.

Left: finished particles stay in the array. Right: they are removed, and the count stays flat.
Left: finished particles stay in the array. Right: they are removed, and the count stays flat.

Two common ways to remove them:

  • Loop backwards and splice a particle out when life >= maxLife. Going backwards keeps the index correct after a removal.
  • Or rebuild the array once per frame with particles = particles.filter(p => p.life < p.maxLife).

Also drop particles that have left the visible area for good. For one-off effects such as confetti, stop calling requestAnimationFrame when the array is empty, so an idle page does no work at all.

Lines between nearby particles

The "network" background is the same loop plus one extra step: for every pair of dots closer than some distance, draw a line whose opacity falls as the gap grows.

Move the pointer over it to push the dots away and link them to the pointer.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Particle network background</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #0b1020; color: #e2e8f0; }
  .hero { position: relative; height: 330px; overflow: hidden; }
  .hero canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
  .hero h1 { position: relative; margin: 0; padding: 120px 20px 0; text-align: center; font-size: 26px; pointer-events: none; }
  .stats { position: absolute; left: 10px; top: 10px; font: 12px/1.4 ui-monospace, Consolas, monospace; background: rgba(0,0,0,.55); padding: 6px 8px; border-radius: 6px; }
  .controls { display: flex; gap: 10px; align-items: center; padding: 10px 14px; font-size: 14px; background: #111827; }
  output { color: #7dd3fc; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div class="hero">
  <canvas id="c"></canvas>
  <h1>Move the pointer here</h1>
  <div class="stats" id="stats">fps -</div>
</div>
<div class="controls">
  <label>Dots <input id="n" type="range" min="20" max="400" value="80"></label> <output id="nOut">80</output>
</div>

<script>
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  const LINK = 110;                 // max distance for a line, CSS px
  const pointer = { x: -9999, y: -9999 };
  let dots = [], w = 0, h = 0;

  function resize() {
    const dpr = window.devicePixelRatio || 1;
    w = canvas.clientWidth; h = canvas.clientHeight;
    canvas.width = Math.round(w * dpr);
    canvas.height = Math.round(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  new ResizeObserver(resize).observe(canvas);
  resize();

  function setCount(n) {
    while (dots.length < n) dots.push({
      x: Math.random() * w, y: Math.random() * h,
      vx: (Math.random() - 0.5) * 40, vy: (Math.random() - 0.5) * 40, size: 1.5 + Math.random() * 1.5
    });
    dots.length = n;
  }
  const slider = document.getElementById('n');
  slider.addEventListener('input', () => { setCount(+slider.value); document.getElementById('nOut').value = slider.value; });
  setCount(+slider.value);

  const hero = canvas.parentElement;
  hero.addEventListener('pointermove', (e) => { const r = canvas.getBoundingClientRect(); pointer.x = e.clientX - r.left; pointer.y = e.clientY - r.top; });
  hero.addEventListener('pointerleave', () => { pointer.x = pointer.y = -9999; });

  let last = performance.now(), frames = 0, fpsStart = last, fps = 0;
  function frame(now) {
    const dt = Math.min((now - last) / 1000, 0.05);
    last = now;
    ctx.clearRect(0, 0, w, h);

    for (const d of dots) {
      // gentle push away from the pointer
      const px = d.x - pointer.x, py = d.y - pointer.y, pd = px * px + py * py;
      if (pd < 100 * 100 && pd > 1) { const dist = Math.sqrt(pd), f = (100 - dist) * 30 * dt; d.vx += px / dist * f; d.vy += py / dist * f; }
      d.vx *= 1 - 0.8 * dt; d.vy *= 1 - 0.8 * dt;         // friction
      d.vx += (Math.random() - 0.5) * 20 * dt; d.vy += (Math.random() - 0.5) * 20 * dt; // drift
      d.x += d.vx * dt; d.y += d.vy * dt;
      if (d.x < 0 || d.x > w) { d.vx *= -1; d.x = Math.max(0, Math.min(w, d.x)); }
      if (d.y < 0 || d.y > h) { d.vy *= -1; d.y = Math.max(0, Math.min(h, d.y)); }
    }

    // Every pair once: n * (n - 1) / 2 distance checks per frame
    let lines = 0;
    ctx.lineWidth = 1;
    for (let i = 0; i < dots.length; i++) {
      for (let j = i + 1; j < dots.length; j++) {
        const dx = dots[i].x - dots[j].x, dy = dots[i].y - dots[j].y;
        const d2 = dx * dx + dy * dy;           // squared: no Math.sqrt for far pairs
        if (d2 > LINK * LINK) continue;
        ctx.strokeStyle = `rgba(125, 211, 252, ${1 - Math.sqrt(d2) / LINK})`;
        ctx.beginPath(); ctx.moveTo(dots[i].x, dots[i].y); ctx.lineTo(dots[j].x, dots[j].y); ctx.stroke();
        lines++;
      }
    }
    // The pointer is one more point: link it to every dot in range
    ctx.strokeStyle = 'rgba(251, 191, 36, .6)';
    for (const d of dots) {
      if ((d.x - pointer.x) ** 2 + (d.y - pointer.y) ** 2 > LINK * LINK) continue;
      ctx.beginPath(); ctx.moveTo(pointer.x, pointer.y); ctx.lineTo(d.x, d.y); ctx.stroke();
    }
    ctx.fillStyle = '#e0f2fe';
    for (const d of dots) { ctx.beginPath(); ctx.arc(d.x, d.y, d.size, 0, Math.PI * 2); ctx.fill(); }

    frames++;  // frames counted over each half second
    if (now - fpsStart >= 500) { fps = Math.round(frames * 1000 / (now - fpsStart)); frames = 0; fpsStart = now; }
    const pairs = dots.length * (dots.length - 1) / 2;
    document.getElementById('stats').textContent = `fps ${fps} | dots ${dots.length} | pairs checked ${pairs} | lines ${lines}`;
    raf = requestAnimationFrame(frame);
  }

  // Stop the loop while the tab is hidden, and restart without a time jump
  let raf = requestAnimationFrame(frame);
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) { cancelAnimationFrame(raf); }
    else { last = fpsStart = performance.now(); frames = 0; raf = requestAnimationFrame(frame); }
  });
</script>
</body>
</html>
Floating dots linked by lines, with a live fps, pair and line count. Raise the dot count to watch the cost grow.

Checking every pair means n × (n − 1) / 2 distance checks per frame. That grows with the square of the dot count, often written O(n²).

Doubling the dots roughly quadruples the pair checks.
Doubling the dots roughly quadruples the pair checks.
Dots Pair checks per frame
50 1,225
100 4,950
200 19,900
400 79,800

Two habits keep it affordable. Compare squared distances, dx * dx + dy * dy against max * max, so far pairs skip Math.sqrt.

And keep the count modest, or scale it with the canvas area. For much larger counts, sort the dots into grid cells and compare only neighbouring cells.

A sharp canvas that follows its box

A canvas has two sizes: the pixel buffer set by width and height, and the box set by CSS. If the buffer is smaller than the box times devicePixelRatio, particles look soft on phones and high-density screens.

function resize() {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.round(canvas.clientWidth * dpr);
  canvas.height = Math.round(canvas.clientHeight * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // keep drawing in CSS px
}
new ResizeObserver(resize).observe(canvas);

Setting width or height clears the canvas and resets the context, including its transform. That is why setTransform runs inside resize, every time.

A ResizeObserver catches size changes that do not come from the window, such as a sidebar opening. HTML canvas blurry explains the two sizes in detail.

Pause when hidden, and respect reduced motion

Browsers may pause requestAnimationFrame in a background tab. You can also stop the loop yourself on visibilitychange, and reset the timestamp when the page comes back:

document.addEventListener('visibilitychange', () => {
  if (document.hidden) cancelAnimationFrame(raf);
  else { last = performance.now(); raf = requestAnimationFrame(frame); }
});

Some people set their system to reduce motion. The prefers-reduced-motion media query reports it, and matchMedia reads it from JavaScript. For a decorative background, draw one still frame or skip it. For confetti, show the success message without the burst.

A finished example: confetti on success

This is the moment particles are most often wanted: a burst when a form is sent or an order completes. Press the button.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Confetti burst</title>
<style>
  body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; }
  .card { width: min(320px, 86vw); padding: 22px; border-radius: 14px; background: #fff; box-shadow: 0 6px 20px rgba(0,0,0,.1); text-align: center; }
  button { font: 600 16px system-ui, sans-serif; padding: 12px 22px; border: 0; border-radius: 10px; background: #16a34a; color: #fff; cursor: pointer; }
  button:active { transform: scale(.97); }
  .msg { height: 22px; margin-top: 12px; font-weight: 600; color: #15803d; }
  .small { font-size: 13px; color: #6b7280; margin-top: 10px; }
  /* The canvas covers the page but never blocks clicks */
  #fx { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; }
</style>
</head>
<body>
<div class="card">
  <p style="margin-top:0">Order #1042 is ready to send.</p>
  <button id="go">Complete order</button>
  <div class="msg" id="msg" aria-live="polite"></div>
  <div class="small">Live particles: <span id="count">0</span> · loop <span id="loop">stopped</span></div>
  <label class="small" style="display:block"><input type="checkbox" id="rm"> Pretend reduced motion is on</label>
</div>
<canvas id="fx"></canvas>

<script>
  const canvas = document.getElementById('fx');
  const ctx = canvas.getContext('2d');
  const colors = ['#f43f5e', '#f59e0b', '#10b981', '#3b82f6', '#a855f7'];
  const reduce = matchMedia('(prefers-reduced-motion: reduce)');
  let pieces = [], running = false, last = 0;

  function resize() {
    const dpr = window.devicePixelRatio || 1;
    canvas.width = Math.round(innerWidth * dpr);
    canvas.height = Math.round(innerHeight * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  addEventListener('resize', resize);
  resize();

  function burst(x, y, n) {
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2, s = 200 + Math.random() * 450;
      pieces.push({
        x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 250,
        w: 6 + Math.random() * 6, h: 4 + Math.random() * 4,
        rot: Math.random() * 6, spin: (Math.random() - 0.5) * 12,
        life: 0, maxLife: 1.6 + Math.random() * 0.8,
        color: colors[i % colors.length]
      });
    }
    if (!running) { running = true; last = performance.now(); requestAnimationFrame(tick); }
  }

  function tick(now) {
    const dt = Math.min((now - last) / 1000, 0.05);
    last = now;
    ctx.clearRect(0, 0, innerWidth, innerHeight);

    for (const p of pieces) {
      p.life += dt;
      p.vy += 900 * dt;                  // gravity
      p.vx *= Math.pow(0.35, dt);        // air drag, frame-rate independent
      p.vy *= Math.pow(0.35, dt);
      p.x += p.vx * dt; p.y += p.vy * dt;
      p.rot += p.spin * dt;
      ctx.save();
      ctx.globalAlpha = Math.max(0, 1 - Math.max(0, p.life - p.maxLife + 0.5) / 0.5);  // fade in the last half second
      ctx.translate(p.x, p.y); ctx.rotate(p.rot);
      ctx.fillStyle = p.color;
      ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h * Math.abs(Math.cos(p.rot * 1.5)));  // flip effect
      ctx.restore();
    }
    // Clean up: keep only pieces that are alive and still on screen
    pieces = pieces.filter(p => p.life < p.maxLife && p.y < innerHeight + 40);

    document.getElementById('count').textContent = pieces.length;
    if (pieces.length) requestAnimationFrame(tick);
    else { running = false; ctx.clearRect(0, 0, innerWidth, innerHeight); }  // nothing left: stop the loop
    document.getElementById('loop').textContent = running ? 'running' : 'stopped';
  }

  document.getElementById('go').addEventListener('click', (e) => {
    const msg = document.getElementById('msg');
    msg.textContent = 'Order complete ✓';
    if (reduce.matches || document.getElementById('rm').checked) return;  // no motion, the message is enough
    const r = e.currentTarget.getBoundingClientRect();
    burst(r.left + r.width / 2, r.top + r.height / 2, 120);
  });
</script>
</body>
</html>
A confetti burst from the button. The loop starts on click and stops when the last piece is gone. Tick the box to see the reduced-motion path.
  • Over the page, not in the way: the canvas is position: fixed over the whole page with pointer-events: none, so clicks go through to the content.
  • Pieces, not dots: each piece is a small rectangle with a rotation and spin. Scaling its height by Math.cos of the rotation makes it look like it flips.
  • The message comes first: the text "Order complete" is set before the burst and sits in an aria-live region, so it works with or without motion.
  • Clean up: pieces are filtered out when they expire or fall off screen, and the loop stops at zero. The live count under the button shows it.

When it does not work

What you see Cause Fix
Particles look blurry or soft Canvas buffer smaller than its box times devicePixelRatio Size the buffer from clientWidth × dpr and call setTransform
Animation runs faster on some screens Movement is per frame, and refresh rates differ Speeds in px per second, multiplied by dt
Everything jumps after switching tabs A huge dt after the pause Cap dt, or reset last on visibilitychange
Slows down as you add particles Lines check every pair each frame Fewer dots, squared distances, or a grid
Gets slower the longer the page is open Dead particles never leave the array splice or filter them out every frame
Drawing stretches when the box changes size Buffer only set once, on load Resize from a ResizeObserver
Everything drawn later is faint globalAlpha left below 1 Reset it after the particle loop, or use save and restore
Buttons under the effect stop working The canvas covers them and takes the clicks pointer-events: none on the canvas

Motion is hard to judge from a screenshot or a video clip. Someone reviewing a confetti moment or a hero background needs to click and move the pointer themselves, ideally on their own phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can press the button and watch the particles themselves. If you change the code later, the same link shows the new version.

Questions people ask

Can I make particles with HTML and CSS only?

A handful, yes: a few absolutely positioned elements with CSS animations can drift or fall. For hundreds of particles that respond to gravity, the pointer or each other, a canvas and a short JavaScript loop is simpler and lighter than hundreds of DOM elements.

How many particles can a canvas handle?

It depends on the device and on what each particle does. Plain moving dots are cheap. Lines between nearby dots are the expensive part, because every pair is checked each frame. Put an fps readout on the page and test on the slowest phone you care about.

Why do my particles move faster on some screens?

The code moves them a fixed amount per frame, and requestAnimationFrame runs at the display's refresh rate. Store velocity in pixels per second and multiply by the time since the last frame instead.

How do I put a particle background behind my page content?

Position the canvas absolutely or fixed inside the section, give it the full size of that box, and put the content above it. Add pointer-events: none to the canvas if it should not catch clicks.

Should confetti respect reduced motion settings?

Yes. Check matchMedia('(prefers-reduced-motion: reduce)') before starting the burst and show a plain success message instead. The message should be there in both cases, so the confetti is decoration, not the only feedback.

Keep reading