Skip to main content

Command Palette

Search for a command to run...

I Animated 250 Particles in React and It Froze. Canvas Fixed It in 100 Lines.

Published
6 min read
I Animated 250 Particles in React and It Froze. Canvas Fixed It in 100 Lines.

Introduction

We needed a celebration effect. Confetti, stars, bubbles exploding across the entire screen. Hundreds of them, all at once, chaotic and fun.

It went from smooth to stuttering to buttery again. Here's what happened.

Attempt 1: Framer Motion on DOM elements

The first version was the obvious one. Each particle is a motion.div with its own animation props. Framer Motion handles the interpolation.

{
  particles.map((p) => (
    <motion.div
      key={p.id}
      className="absolute"
      style={{
        left: `${p.x}%`,
        top: `${p.y}%`,
        width: p.size,
        height: p.size,
        backgroundColor: p.color,
        borderRadius: p.shape === 'circle' ? '50%' : '2px',
      }}
      initial={{ x: 0, y: 0, rotate: 0, opacity: 0, scale: 0 }}
      animate={{
        x: [0, launchX, launchX + p.driftX],
        y: [0, launchY, launchY + 300],
        rotate: [p.rotation, p.rotation + p.spinSpeed],
        opacity: [0, 1, 1, 0],
        scale: [0, 1.3, 1, 0.6],
      }}
      transition={{ duration: p.duration, ease: [0.22, 1, 0.36, 1] }}
    />
  ))
}

With 40 particles this was fine. Looked great. We bumped it to 120. Still okay. Then we wanted the celebration to feel real. Full screen coverage, every corner, pure chaos. We pushed to 250 particles.

The problem: death by style recalculation

It lagged. Hard.

A Chrome performance trace told the story. Every single animation frame, Framer Motion was:

  1. Calculating new values for 250 springs/tweens in JavaScript

  2. Setting inline styles on 250 DOM nodes (transform, opacity)

  3. Triggering the browser's style recalculation engine for all 250 elements

  4. Triggering paint for each affected region

Step 3 is the killer. The browser's style engine is designed for documents, not particle systems. It checks cascade rules, computes inheritance, resolves layout dependencies. Doing that 250 times per frame at 60fps means 15,000 style recalculations per second. The main thread can't keep up.

Each particle is visually trivial. A tiny colored rectangle or circle. But the browser doesn't know that. Every motion.div is a full DOM citizen with the same overhead as a complex nested component.

Why will-change and CSS animations don't save you

The usual tricks don't help at this scale.

will-change: transform promotes elements to their own compositor layers. That helps with paint because the GPU composites pre-rasterized layers without re-painting. But you still pay the cost of 250 JavaScript style updates per frame. The bottleneck is the JS to style bridge, not the paint itself.

CSS @keyframes can run on the compositor thread, but you'd need to pre-generate unique keyframes for each particle's random trajectory. And you lose the physics. No gravity, no per-frame velocity updates. CSS animations are great for predetermined motion, not for simulations.

The fix: one <canvas>, zero DOM nodes

Games solved this decades ago. Don't make the browser manage 250 elements. Draw 250 shapes yourself.

function ConfettiRain({ playing, onComplete }) {
  const particles = useMemo(() => generateParticles(), [playing])
  const canvasRef = useCanvasParticles({ playing, particles, onComplete })

  if (!playing) return null
  return <canvas ref={canvasRef} className="fixed inset-0 z-[60]" />
}

One DOM element. One canvas. The particle system is a requestAnimationFrame loop that mutates an array of plain objects in place:

const loop = (now: number) => {
  const dt = (now - lastTime) / 1000
  ctx.clearRect(0, 0, width, height)

  for (const p of particles) {
    // Physics. Mutate in place, zero allocations.
    p.vy += p.gravity * dt
    p.x += p.vx * dt
    p.y += p.vy * dt
    p.rotation += p.rotationSpeed * dt

    // Draw
    ctx.save()
    ctx.translate(p.x, p.y)
    ctx.rotate(p.rotation)
    ctx.globalAlpha = computeAlpha(p)
    ctx.fillStyle = p.color
    ctx.fillRect(-half, -half, p.size, p.size)
    ctx.restore()
  }

  requestAnimationFrame(loop)
}

Why this is fast

No style recalculations. The browser's style engine never runs. There are no DOM elements to restyle. Just one canvas that never changes its own CSS.

No layout. Canvas drawing commands don't trigger layout. fillRect and arc go straight to the GPU-backed bitmap.

No React re-renders. The particle array is mutated in place inside requestAnimationFrame. React doesn't know or care that 250 objects are being updated 60 times per second. The component rendered exactly once.

Minimal GC pressure. No objects are created per frame. The particle array is allocated once in useMemo. The loop just mutates numbers on existing objects.

One draw call per shape. Canvas 2D batches fill operations efficiently. Drawing 250 rectangles on a canvas is trivially cheap compared to managing 250 DOM nodes.

The coordinate space gotcha

We hit one bug that made everything invisible. Particles had their positions as normalized 0 to 1 fractions (so x: 0.5 means center of screen) but velocities in pixels per second. The update loop added pixel velocities to fractional positions:

// Bug: adds ~300 to a value between 0 and 1
p.x += p.vx * dt

After one frame, every particle was at x = 247.5. Way past the right edge of a 0 to 1 coordinate space. Everything was flying off screen instantly.

The fix: convert positions from fractions to pixels on the first frame, then everything stays in pixel space.

if (!isInitialized) {
  for (const p of particles) {
    p.x *= window.innerWidth
    p.y *= window.innerHeight
  }
  isInitialized = true
}

Simple. Positions start as fractions so the generator doesn't need to know the viewport size. On the first frame of the animation loop the canvas is mounted and we have real pixel dimensions, so we multiply once. After that, positions and velocities are both in pixels and the math just works.

The result

Same 250 particles. Same visual chaos. Confetti tumbling across the entire screen, stars exploding from multiple origins, bubbles floating bottom to top. But now it runs at a locked 60fps with no frame drops. The Chrome performance trace shows a flat, boring main thread. Exactly what you want.

When to reach for canvas in React

Framer Motion and the DOM are perfect for UI animation. Modals, tooltips, layout transitions, interactive components. Anything where you're animating a handful of elements that the user interacts with.

But when you cross into particle system territory, dozens to hundreds of elements with no interactivity, driven by physics, appearing and disappearing rapidly, you're fighting the DOM's architecture. Every element you add to the document is a commitment. The browser will track its styles, its layout, its paint, its accessibility, its event handlers. That's overhead you're paying for but not using.

Canvas opts out of all of it. One element, your own render loop, draw whatever you want. The tradeoff is you lose CSS, accessibility for those elements, and click handlers. For decorative particle effects that's a trade worth making every time.

The pattern is simple. useMemo for particle generation, useRef for the canvas, useEffect with requestAnimationFrame for the loop. No libraries needed. The whole hook is about 100 lines.