EmpireUI
Get Pro
← Blog9 min read#canvas#particles#javascript

Canvas Particle System From Scratch: Mouse Interaction, Color Fields

Build a canvas particle system with mouse interaction and color fields from scratch — no libraries, just vanilla JS, requestAnimationFrame, and a bit of math.

Colorful glowing particles scattered across a dark digital canvas background

Why Canvas, Why Now

WebGL gets all the glory, but the 2D Canvas API is still the sharpest tool for particle work when you don't need 3D transforms. It's synchronous, predictable, and you can get 2,000 particles running at 60 fps without touching a shader. In practice, most interactive hero sections and generative art pieces you see in 2026 are still vanilla canvas under the hood.

That said, canvas has one catch that bites everyone eventually: you own the render loop. There's no diffing, no batching, no magic. You clear the frame, draw every particle, and get out. That constraint is actually why particle systems are so instructive — they force you to think about data layout, object pooling, and frame budget in a way React abstractions never do.

This guide builds everything from scratch. No Three.js, no tsParticles, no shortcuts. You'll end up with a system that responds to mouse position, shifts color based on a flowing field, and stays above 55 fps on mid-range hardware. If you want to grab pre-built interactive components afterward, browse components — but understanding what's under those components will make you a better consumer of them.

Setting Up the Canvas and Render Loop

Start with the HTML shell. One canvas tag, full-screen, nothing else. The important detail people skip: you need to set canvas.width and canvas.height to window.innerWidth and window.innerHeight in JS — not in CSS. CSS scales the element; the properties define the drawing surface resolution. Get that wrong and your coordinates will be off by whatever your device pixel ratio is.

<canvas id="c"></canvas>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  canvas { display: block; background: #0a0a0f; }
</style>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');

function resize() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);

Quick aside: getContext('2d', { alpha: false }) disables the alpha channel on the canvas context and gives you a measurable perf boost — sometimes 10–15% on cheaper GPUs — because the compositor skips the blend pass. Worth it when your background is a solid color anyway.

The render loop is just requestAnimationFrame calling itself. Don't use setInterval. rAF syncs with the display refresh, respects tab visibility, and doesn't drift. Clear the entire canvas at the start of each frame with ctx.clearRect(0, 0, canvas.width, canvas.height) — or fill it with a semi-transparent black if you want motion trails (more on that later).

The Particle Class: Physics You Actually Need

You don't need a physics engine. You need four numbers per particle: x, y, vx, vy. Velocity accumulates into position each frame. That's the whole physics model. Everything else — color, size, lifespan — is decoration.

class Particle {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = (Math.random() - 0.5) * 2;
    this.vy = (Math.random() - 0.5) * 2;
    this.life = 1.0;           // 0 = dead, 1 = fresh
    this.decay = 0.008 + Math.random() * 0.004;
    this.radius = 2 + Math.random() * 2;
    this.hue = Math.random() * 360;
  }

  update() {
    this.x += this.vx;
    this.y += this.vy;
    this.life -= this.decay;
    // subtle drag
    this.vx *= 0.99;
    this.vy *= 0.99;
  }

  draw(ctx) {
    ctx.save();
    ctx.globalAlpha = Math.max(0, this.life);
    ctx.fillStyle = `hsl(${this.hue}, 90%, 65%)`;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

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

Honestly, the 0.99 drag multiplier is one of those magic numbers worth keeping. Without it, particles drift offscreen in about 3 seconds. With it, they decelerate naturally and cluster in the center of their birth region — which looks way more organic.

Worth noting: calling ctx.save() and ctx.restore() inside every particle's draw() is expensive at scale. For 500+ particles, batch your state changes outside the loop — set globalAlpha once per draw call if all particles share the same alpha, or use ctx.globalCompositeOperation = 'lighter' at the top of the frame for a bloom-like additive blend that naturally handles alpha without per-particle saves.

Spawn particles into an array. Each frame, update all of them, remove the dead ones, draw the live ones. The remove step is the one that bites you performance-wise — splicing a 2000-element array every frame is O(n). Use swap-and-pop instead: swap the dead particle with the last element and decrement the length.

Mouse Interaction: Repulsion and Attraction

Track the mouse with a simple event listener. Store mouse.x and mouse.y globally. Then in each particle's update, calculate the vector from particle to mouse, get its magnitude, and apply a force inversely proportional to distance.

const mouse = { x: -9999, y: -9999, radius: 120 };
window.addEventListener('mousemove', e => {
  mouse.x = e.clientX;
  mouse.y = e.clientY;
});

// Inside Particle.update():
const dx = this.x - mouse.x;
const dy = this.y - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);

if (dist < mouse.radius && dist > 0) {
  const force = (mouse.radius - dist) / mouse.radius;
  const angle = Math.atan2(dy, dx);
  // repulsion — flip sign for attraction
  this.vx += Math.cos(angle) * force * 0.8;
  this.vy += Math.sin(angle) * force * 0.8;
}

The mouse.radius of 120 pixels gives you a comfortable interaction bubble without being grabby. Go above 200px and it starts feeling like the particles are glued to your cursor, which is weird. Below 60px and users won't notice the interaction unless they're deliberately hunting for it.

One more thing — Math.sqrt in a tight loop with thousands of particles adds up. The classic optimization is to compare dist * dist against mouse.radius * mouse.radius to skip the sqrt on the rejection check, then only compute the actual distance when you know the particle is in range. It's a small win per frame but compounds noticeably when you're spawning 1,500 particles.

For click bursts, spawn 30–50 particles at the click coordinates with random outward velocities. That's e.clientX and e.clientY straight from the MouseEvent — no coordinate transformation needed since you sized the canvas to match the viewport.

Color Fields: Making Particles React to Position

A color field is just a function that takes (x, y, time) and returns a hue. The simplest one is a sine wave over position: hue = (x / canvas.width * 180 + time * 30) % 360. This creates a slow color sweep across the canvas. Particles born on the left will be different hues than particles born on the right.

function colorField(x, y, t) {
  // Flowing color based on position + time
  const nx = x / canvas.width;  // normalize 0..1
  const ny = y / canvas.height;
  const hue = (Math.sin(nx * 3 + t * 0.5) * 60 +
               Math.cos(ny * 3 - t * 0.3) * 60 + 200) % 360;
  return Math.abs(hue);
}

Set each particle's hue at spawn time: this.hue = colorField(x, y, Date.now() / 1000). You can also update hue every frame for a constantly shifting color — it's more expensive but looks spectacular, especially when combined with glassmorphism components overlaid on the canvas. That translucent glass layer over a shifting particle field is a killer effect.

Perlin noise gives you a smoother, more organic color field than sine waves. You'd need a Perlin implementation (Stefan Gustavson's 2012 JS port is the one everyone uses), but the API is the same: noise.simplex2(x * 0.003, y * 0.003 + t * 0.1) * 180 + 180. The 0.003 frequency scale is important — too high and you get TV static noise, too low and it looks identical to a sine field anyway.

Look, the real visual payoff comes from combining the mouse repulsion with color field updates. When your cursor moves through a field, it displaces particles into color zones they weren't born in. That contrast — the particle's original hue sliding toward a new hue as it travels — is what makes these systems feel alive rather than mechanical. Tie this into your CSS animation work: css scroll animations can trigger particle bursts on scroll, giving you a full-page narrative effect.

Motion Trails and Additive Blending

Replace ctx.clearRect with a semi-transparent fill and your particles leave trails. The trick is ctx.fillStyle = 'rgba(10, 10, 15, 0.15)' — use your background color at low opacity. Every frame paints a faint layer over the previous frame, gradually fading old positions. The 0.15 value controls trail length: lower alpha = longer trail.

function render(timestamp) {
  // Fade instead of clear
  ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Additive blending makes overlapping particles bloom
  ctx.globalCompositeOperation = 'lighter';

  particles.forEach(p => {
    p.update();
    p.draw(ctx);
  });

  // Remove dead particles (swap-and-pop)
  for (let i = particles.length - 1; i >= 0; i--) {
    if (particles[i].isDead()) {
      particles[i] = particles[particles.length - 1];
      particles.pop();
    }
  }

  // Reset composite mode
  ctx.globalCompositeOperation = 'source-over';

  requestAnimationFrame(render);
}

requestAnimationFrame(render);

globalCompositeOperation = 'lighter' is the additive blend mode — overlapping particles add their color values together, pushing toward white at the center of clusters. It's what gives particle systems that neon glow without any blur or shadow calls. Blur is expensive. Additive blending is nearly free.

Worth noting: if you're layering this under a UI — say, a login card or a hero section — you'll want source-over not lighter for the background itself, or your UI elements will look washed out. Reset the composite mode before you draw any HTML-overlay elements, or better yet, put your UI in a separate <div> positioned absolutely over the canvas with pointer-events: none on the canvas.

The gradient generator tool is great for picking your background fill color — you want something dark enough that the rgba trail fade reads as the background color, not as an unwanted tint. A deep navy #070715 or near-black #0a0a0f both work well.

Performance: Keeping It Above 55 fps

Frame budget on a 60 Hz display is 16.7ms. Your particle update-and-draw loop needs to fit inside that. At 1,000 particles with trail fading and mouse interaction, you're typically around 4–6ms on a mid-range 2024 laptop. At 3,000 particles it starts pushing 12–14ms, and on mobile you'll drop frames.

The two biggest wins outside of reducing particle count: avoid per-particle ctx.save()/ctx.restore() as mentioned above, and batch your beginPath() calls. Instead of one arc per particle, you can draw all same-radius particles in a single path call — moveTo then arc for each, then one fill() at the end. It cuts draw call overhead substantially when particles cluster.

Profile with Chrome DevTools Performance tab. Record a 3-second segment and look at the Rendering row. If you see long frames, the first suspect is always the draw loop — expand it and check whether arc() or fill() dominates. In 2025 Chrome introduced better canvas flame chart detail that makes this much easier to read than it used to be.

Cap your particle count with a MAX_PARTICLES constant. Spawn new ones only when particles.length < MAX_PARTICLES. This prevents the system from spiraling on high-DPI retina screens where the canvas surface is 2x the pixel count. Speaking of which — you might want to use devicePixelRatio to scale your canvas for crisp rendering on retina, but that halves your effective frame budget since you're drawing 4x the pixels. Pick one: crisp or fast. For particles, fast usually wins.

Object pooling is the nuclear option if you're still struggling. Pre-allocate an array of 2,000 Particle objects at startup, reset them instead of creating new ones. Garbage collection pauses are the invisible enemy in long-running canvas animations — a GC pause of even 8ms spikes your frame time and causes a visible stutter. Pool your objects and you eliminate almost all allocation pressure. This pairs well with techniques from framer motion advanced — knowing when to offload animation to the GPU vs CPU is the same decision-making muscle.

FAQ

How many particles can canvas handle at 60 fps?

Realistically 1,000–2,500 on a mid-range laptop with vanilla 2D canvas. Beyond that you need WebGL or you accept dropped frames — GPU-accelerated WebGL can handle 100,000+ particles trivially.

Do I need a library like tsParticles or Three.js for this?

Not for a 2D system. Vanilla canvas with requestAnimationFrame handles mouse interaction and color fields fine. Libraries add config overhead and bundle weight that you just don't need for a focused effect.

Why does my canvas look blurry on retina screens?

You're not accounting for devicePixelRatio. Multiply canvas.width and canvas.height by window.devicePixelRatio, then scale the context down with ctx.scale(dpr, dpr) — your CSS size stays the same but the drawing surface is sharp.

Can I use this particle system behind a React component?

Yes — mount the canvas with a useEffect, store the animation frame ID in a ref, and cancel it in the cleanup function. The canvas logic is framework-agnostic; React just manages the mount/unmount lifecycle.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

HTML Canvas Animations in React: Particles, Noise Fields, MoreThree.js Particle Systems: BufferGeometry, Points and GPU ComputeConfetti in React 2026: canvas-confetti, tsParticles and Pure CSSCanvas Animations in React: requestAnimationFrame, Particles, Paths