EmpireUI
Get Pro
← Blog8 min read#canvas#react#animation

HTML Canvas Animations in React: Particles, Noise Fields, More

Learn how to wire up HTML Canvas animations in React — particle systems, Perlin noise fields, and requestAnimationFrame loops that don't leak memory.

colorful abstract particle animation rendered on a dark digital canvas

Why Canvas Still Beats CSS for Complex Animations

Canvas gives you a raw pixel buffer. No DOM overhead, no style recalculation, no composite layers fighting each other. When you're pushing 2,000 particles at 60fps, CSS animations will buckle. Canvas won't — or at least, it won't buckle first.

That said, this isn't an either-or situation. CSS handles micro-interactions brilliantly: hover states, transitions under 300ms, the kind of polish you'd find across the glassmorphism components on Empire UI. Canvas is for when you need to move a lot of things independently, update positions on every frame, or draw procedurally generated visuals that CSS can't express.

Look, the real reason devs avoid Canvas in React is the ref dance and the cleanup story. Both are solvable. Give me five minutes and you'll have a pattern you can reuse forever.

Setting Up a Canvas in React Without Shooting Yourself

The core pattern is dead simple: a useRef pointing at the canvas element, a useEffect that grabs the 2D context, and a cancelAnimationFrame call in the cleanup. Miss that cleanup and you'll stack animation loops every time the component remounts. Hot-reload in development will expose this fast — you'll see the animation accelerating like it's haunted.

Here's a minimal working shell you can drop into any project:

import { useRef, useEffect } from 'react';

export default function CanvasScene() {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    let animId;

    canvas.width = canvas.offsetWidth;
    canvas.height = canvas.offsetHeight;

    function draw() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // your drawing logic here
      animId = requestAnimationFrame(draw);
    }

    draw();
    return () => cancelAnimationFrame(animId);
  }, []);

  return <canvas ref={canvasRef} style={{ width: '100%', height: '400px' }} />;
}

Worth noting: you need to set canvas.width and canvas.height from the DOM dimensions, not CSS. CSS width: 100% doesn't set the drawing buffer — it just scales it. If you skip this step, everything renders blurry on retina screens. For HiDPI, multiply by window.devicePixelRatio and scale the context.

One more thing — the dependency array on that useEffect matters. If your animation depends on props (say, a color or speed config), include them. You'll need to cancel and restart the loop when they change.

Building a Particle System from Scratch

Particles are the gateway drug to canvas. The pattern is always the same: maintain an array of particle objects, update their position each frame, draw them, repeat. The interesting part is what "update" means for your use case.

Here's a 50-particle system with velocity, fade, and respawn logic — self-contained, ready to drop in:

import { useRef, useEffect } from 'react';

function makeParticle(w, h) {
  return {
    x: Math.random() * w,
    y: Math.random() * h,
    vx: (Math.random() - 0.5) * 2,
    vy: (Math.random() - 0.5) * 2,
    radius: Math.random() * 3 + 1,
    alpha: Math.random(),
  };
}

export default function Particles() {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    canvas.width = canvas.offsetWidth;
    canvas.height = canvas.offsetHeight;
    const { width: W, height: H } = canvas;

    let particles = Array.from({ length: 50 }, () => makeParticle(W, H));
    let animId;

    function tick() {
      ctx.clearRect(0, 0, W, H);
      particles.forEach((p) => {
        p.x += p.vx;
        p.y += p.vy;
        p.alpha -= 0.004;
        if (p.alpha <= 0) Object.assign(p, makeParticle(W, H));

        ctx.beginPath();
        ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(147, 51, 234, ${p.alpha})`;
        ctx.fill();
      });
      animId = requestAnimationFrame(tick);
    }

    tick();
    return () => cancelAnimationFrame(animId);
  }, []);

  return <canvas ref={canvasRef} style={{ width: '100%', height: '500px', background: '#0a0a0a' }} />;
}

In practice, 50 particles on a dark background with a purple rgba fill lands perfectly on most dark UI themes — the kind of aesthetic you'd see throughout Empire UI components. Push it to 500 particles and you should still hold 60fps on a mid-range laptop from 2023, because canvas batches draw calls efficiently at this scale.

Once you've got this running, try connecting nearby particles with lines. Check each pair's distance, draw a line with opacity proportional to 1 - dist/maxDist. That's the "constellation" effect you've seen on a thousand landing pages.

Perlin Noise Fields: Making Motion Feel Alive

Random velocity makes particles jittery. Noise fields make them flow. The idea: sample a noise function at each particle's position, use the output as a direction angle, steer the particle that way. The result looks organic — like smoke, or fish schooling.

You won't find native Perlin noise in the browser. Pull in the simplex-noise package (version 4.x, ESM-first). It exports a createNoise2D factory that returns a noise(x, y) function returning values in [-1, 1]. Multiply by Math.PI * 2 and you've got a direction angle in radians.

import { createNoise2D } from 'simplex-noise';
const noise2D = createNoise2D();

// inside tick(), per particle:
const angle = noise2D(p.x * 0.003, p.y * 0.003) * Math.PI * 2;
p.vx += Math.cos(angle) * 0.1;
p.vy += Math.sin(angle) * 0.1;
// clamp speed
const speed = Math.hypot(p.vx, p.vy);
if (speed > 2) { p.vx = (p.vx / speed) * 2; p.vy = (p.vy / speed) * 2; }

The 0.003 scale factor controls how "zoomed in" the noise field is. Bigger number = tighter, more chaotic curves. Smaller = long sweeping arcs. Play with it. Honestly, tuning that single multiplier will eat an hour of your afternoon, and you won't regret it.

Quick aside: you can add a time offset — noise2D(p.x * 0.003 + t * 0.0005, p.y * 0.003) where t increments each frame — to make the field evolve over time instead of staying static. That turns a cool effect into something genuinely mesmerizing.

Performance: Where Things Break and Why

Canvas is fast, but you can still kill it. The two main culprits are overdraw and state changes inside the draw loop. Overdraw is when you fill massive areas repeatedly — a full clearRect on a 1920×1080 canvas every frame at 60fps is moving 8MB of pixels per second. It's fine on desktop, brutal on mobile.

Avoid calling getContext inside the animation loop. Get it once, store the reference. Similarly, don't recreate arrays inside tick(). Pre-allocate particle objects in the useEffect setup and mutate them — don't spread or map a new array each frame.

For 1000+ particles, consider skipping the ctx.beginPath() / ctx.arc() per particle approach and batch into a single path. Or use ctx.fillRect() with a 2×2 pixel square — it's faster than arc for tiny dots and barely noticeable visually. At 10,000 particles, look at offscreen canvas or WebGL. But you probably don't need 10,000 particles.

Worth noting: React Strict Mode in development mounts components twice. Your animation loop runs twice, your cancelAnimationFrame runs once (on the first mount), leaving a zombie loop. This is expected behavior — it only happens in dev. Test in production mode before concluding you have a bug.

Integrating Canvas Animations Into Your Design System

A floating particle field as a section background, a noise-driven aurora behind a hero card, a signature loading spinner — these are the moments where canvas earns its place in a production codebase. The challenge is making the animation feel like it belongs to your design tokens, not like a CodePen demo you copy-pasted.

Accept color as a prop. Accept intensity, speed, and particle count as props with sensible defaults. Use useCallback around your draw function if you need to update those values without restarting the loop — though honestly for most cases, just restarting is cleaner and less buggy.

If you're building on a dark-themed UI — and you probably are, if you've been browsing the aurora or cyberpunk style guides — particles in rgba(255, 255, 255, 0.06) with a subtle blur trail (ctx.globalAlpha = 0.05 on the clear rect instead of full clear) give you that persistence-of-vision streak effect without any extra library.

One more thing — accessibility. Canvas is invisible to screen readers. If your canvas is decorative, add aria-hidden="true" to the element. If it's interactive, you need a fallback or an ARIA live region describing changes. Don't skip this just because it's "just an animation."

What to Try Next

You've got the core pattern. Particle systems, noise fields, memory-safe cleanup — that's 80% of what you'll need for production canvas work in React. The remaining 20% is specialty territory: physics with collision detection, WebGL shaders via Three.js or raw WebGL2, or hooking canvas output into a WebRTC stream.

For design inspiration before you write a single line, spend time in the glassmorphism generator or the gradient generator — pick a palette, then translate those colors into your particle rgba values. The constraint of working from an existing palette keeps the output looking deliberate rather than demo-y.

The component patterns here map directly onto the kind of animated backgrounds you'll find throughout Empire UI. If you want to see canvas-grade motion without building from scratch, browse the components — several of the hero and background components are doing exactly what we walked through above, just wrapped in a polished API.

FAQ

Does using canvas in React cause memory leaks?

Only if you forget to cancel the animation frame in your useEffect cleanup. Call cancelAnimationFrame(animId) in the return function and you're fine — the loop won't survive unmount.

Should I use a library like react-three-fiber instead of raw canvas?

For 2D particle effects and noise fields, raw canvas is simpler and has zero extra dependencies. Reach for Three.js or r3f when you need 3D, lighting models, or shaders — it's overkill for what's covered here.

How do I make the canvas responsive when the window resizes?

Add a ResizeObserver (or a resize event listener) inside your useEffect, update canvas.width and canvas.height, and restart the draw loop. Don't forget to cancel the observer in cleanup too.

Will canvas animations kill battery life on mobile?

An uncapped requestAnimationFrame loop will, yes. Use the Page Visibility API to pause the loop when the tab is hidden, and consider capping your particle count to under 200 on mobile via a media query or navigator.hardwareConcurrency check.

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

Read next

Canvas Particle System From Scratch: Mouse Interaction, Color FieldsAudio Visualizer in React: Web Audio API + Canvas = Waveform MagicCanvas Animations in React: requestAnimationFrame, Particles, PathsConfetti in React 2026: canvas-confetti, tsParticles and Pure CSS