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

Canvas Animations in React: requestAnimationFrame, Particles, Paths

Learn how to drive canvas animations in React with requestAnimationFrame, build particle systems, and trace SVG-style paths — all without a library.

Code editor showing canvas animation code with colorful particle effects

Why the Canvas API still matters in 2026

You'd think WebGL or WebGPU would have killed plain <canvas> by now. They haven't. The 2D Canvas API is still the fastest path from "I want moving stuff on screen" to working code, with zero build-time dependencies and near-universal browser support going back to IE 9. For particle effects, procedural backgrounds, and path-based animations, it often beats reaching for a full animation library by a mile.

That said, there's a reason most React tutorials sidestep it. The canvas API is inherently imperative — you call ctx.fillRect(), you call ctx.clearRect() — while React is declarative. Wiring them together without causing memory leaks or stale closure bugs requires some care. This article walks through exactly that.

Look, once you understand the three-part pattern (ref → loop → cleanup), you can build almost anything: particle fields, animated paths, generative art. If you want to see what finished, polished examples look like before writing a single line of code, browse the Empire UI component library — several backgrounds there run on raw canvas under the hood.

Getting a canvas ref wired into React

The core setup is a useRef attached to a <canvas> element, accessed inside a useEffect. Nothing lands in the DOM until after the first render, so your canvas context will always be null during the render phase — that's expected and fine.

import { useEffect, useRef } from 'react';

export function AnimatedCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // Set actual pixel dimensions
    canvas.width = canvas.offsetWidth;
    canvas.height = canvas.offsetHeight;

    let frameId: number;

    function draw() {
      ctx!.clearRect(0, 0, canvas!.width, canvas!.height);
      // draw your frame here
      frameId = requestAnimationFrame(draw);
    }

    frameId = requestAnimationFrame(draw);

    return () => cancelAnimationFrame(frameId);
  }, []);

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

One thing worth calling out: canvas.offsetWidth vs canvas.width is a real gotcha. The CSS width sets how big the element looks; canvas.width sets the resolution of the drawing surface. If you don't sync them, everything will look blurry on HiDPI screens. For retina sharpness, multiply by window.devicePixelRatio and scale the context accordingly — more on that in the particle section below.

Worth noting: the empty dependency array [] on useEffect means the loop starts once on mount and the cleanup cancels it on unmount. If your animation depends on props, you'd list those as dependencies and re-trigger the loop — but be careful not to start multiple loops at once without cancelling the previous one first.

requestAnimationFrame: the right mental model

A lot of devs reach for setInterval for animation. Don't. requestAnimationFrame (rAF) fires in sync with the browser's repaint cycle — typically 60fps, but it respects 120Hz displays and backs off when the tab is backgrounded. setInterval does neither of those things.

The pattern is recursive: each call to your draw function schedules the *next* frame via another rAF call. The frame ID it returns is what you pass to cancelAnimationFrame in cleanup. If you forget cleanup, you'll get multiple loops running simultaneously after React's strict mode double-invokes your effects — and you'll spend 20 minutes debugging why particles are duplicating.

const draw = (timestamp: number) => {
  const delta = timestamp - lastTime;
  lastTime = timestamp;

  // Use delta for frame-rate-independent movement
  particle.x += particle.vx * (delta / 16.67); // normalize to 60fps

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // ... draw particles

  frameId = requestAnimationFrame(draw);
};

let lastTime = 0;
frameId = requestAnimationFrame((ts) => {
  lastTime = ts;
  draw(ts);
});

Honestly, the delta-based movement is the difference between an animation that looks smooth on a MacBook Pro and one that runs at double speed on a 120Hz gaming monitor. Normalize your velocities against 16.67ms (one frame at 60fps) and you're covered.

Quick aside: requestAnimationFrame passes a DOMHighResTimeStamp as its first argument — that's your timestamp. It's relative to performance.timeOrigin, not a Unix timestamp. You don't need the absolute value; you need the delta between frames.

Building a particle system from scratch

Particles are the canonical canvas demo, and for good reason — they teach you everything at once: object pools, velocity math, lifetime tracking, and alpha fading. Here's a self-contained particle system you can drop into any React project.

interface Particle {
  x: number; y: number;
  vx: number; vy: number;
  life: number; // 0-1
  radius: number;
  color: string;
}

function createParticle(w: number, h: number): Particle {
  return {
    x: Math.random() * w,
    y: Math.random() * h,
    vx: (Math.random() - 0.5) * 2,
    vy: (Math.random() - 0.5) * 2,
    life: 1,
    radius: Math.random() * 3 + 1,
    color: `hsl(${Math.random() * 360}, 80%, 65%)`,
  };
}

function updateParticle(p: Particle, delta: number): Particle {
  return {
    ...p,
    x: p.x + p.vx * (delta / 16.67),
    y: p.y + p.vy * (delta / 16.67),
    life: p.life - 0.005 * (delta / 16.67),
  };
}

function drawParticle(ctx: CanvasRenderingContext2D, p: Particle) {
  ctx.save();
  ctx.globalAlpha = p.life;
  ctx.beginPath();
  ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
  ctx.fillStyle = p.color;
  ctx.fill();
  ctx.restore();
}

Inside your animation loop, filter out dead particles (life <= 0), spawn new ones to maintain a target count, then call drawParticle for each one. The ctx.save() / ctx.restore() pair is important — without it, globalAlpha bleeds into every subsequent draw call.

For retina sharpness, set this up at the start of your useEffect: ``tsx const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; ctx.scale(dpr, dpr); `` Now your 2px radius circles will actually render at 2 CSS pixels, not 2 physical pixels on a 2x display. If you want a ready-made take on particle animations with zero setup, the particles background component on this blog covers a library-backed approach. Or if you want to go deeper with full canvas particle systems, that article covers spawning strategies in detail.

One more thing — object allocation inside the animation loop will murder your frame rate on low-end devices. Avoid new and spread operators inside draw(). Use a pre-allocated pool array and mutate in place. Your garbage collector will thank you.

Animating paths and arcs

Particles are great, but paths are where canvas gets genuinely expressive. You can animate anything along a path — a glowing dot following a bezier curve, a progress ring drawing itself, a neon line tracing a waveform. The 2D canvas API's path drawing methods map almost 1:1 to SVG path commands.

// Animated arc (think: circular progress indicator)
function drawArc(ctx: CanvasRenderingContext2D, progress: number, cx: number, cy: number) {
  const startAngle = -Math.PI / 2;
  const endAngle = startAngle + (Math.PI * 2 * progress);

  ctx.beginPath();
  ctx.arc(cx, cy, 80, startAngle, endAngle); // 80px radius
  ctx.strokeStyle = '#7c3aed';
  ctx.lineWidth = 6;
  ctx.lineCap = 'round';
  ctx.stroke();
}

For bezier-based motion, compute the point-on-curve formula directly — no library needed. A cubic bezier through four control points (p0, p1, p2, p3) at time t is: ``ts function cubicBezier(p0: number, p1: number, p2: number, p3: number, t: number) { const mt = 1 - t; return mt**3*p0 + 3*mt**2*t*p1 + 3*mt*t**2*p2 + t**3*p3; } // x = cubicBezier(x0, cx1, cx2, x3, t) // y = cubicBezier(y0, cy1, cy2, y3, t) ` Drive t` from 0 to 1 over time using your frame delta, and you've got a particle that glides along any curve you specify.

In practice, the most visually striking path animations combine globalCompositeOperation = 'screen' with bright colors on a dark background — this is how you get that neon glow effect without any image assets. Pair it with shadowBlur for extra depth: ``tsx ctx.shadowColor = '#7c3aed'; ctx.shadowBlur = 20; ` Just remember to reset shadowBlur` to 0 after drawing, otherwise every draw call inherits it.

If your project involves heavy visual effects like glassmorphism layered over animated canvas backgrounds, check out the glassmorphism generator — it can help you dial in the right blur and opacity values so the frosted layer doesn't visually compete with what's moving underneath.

Handling resize and cleanup without memory leaks

Canvas animations break in two classic ways: they don't resize when the window does, and they leak RAF loops when the component unmounts. Both are fixable with a bit of setup.

For resize, attach a ResizeObserver instead of a window resize event. ResizeObserver fires when the element itself changes size — which matters if your canvas is inside a flex or grid container that resizes independently of the window. ``tsx useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; let frameId: number; const ro = new ResizeObserver(() => { const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr); }); ro.observe(canvas); // ... start your loop, assign to frameId return () => { cancelAnimationFrame(frameId); ro.disconnect(); }; }, []); ``

The cleanup function handles both the RAF loop and the observer. Two lines, no leaks. In React 18+ strict mode (development), effects mount-unmount-remount, so your cleanup has to be airtight — if it's not, you'll see doubled animation speeds in dev that disappear in production and confuse everyone.

Worth noting: if you're passing props into the animation (colors, speed, count), don't capture them in the initial closure. Use a useRef to hold a mutable config object that the draw loop reads on every frame. That way you can update props without restarting the entire loop: ``tsx const configRef = useRef({ speed: 1, count: 100 }); // In the loop: const { speed, count } = configRef.current; // When props change: useEffect(() => { configRef.current.speed = speed; }, [speed]); ``

This pattern keeps your loop running continuously while props update live — no flicker, no restart.

Performance tips and when to bail out to WebGL

Canvas 2D is fast, but it has limits. Past roughly 2,000 simultaneously drawn, alpha-blended circles at 60fps, you'll start dropping frames on mid-range hardware — especially on mobile. Here are the techniques that actually move the needle.

Offscreen canvas batching is the biggest win. Pre-render static or rarely-changing elements to an offscreen canvas, then ctx.drawImage(offscreenCanvas, 0, 0) onto the main canvas each frame. This turns dozens of draw calls into one image blit. The OffscreenCanvas API also lets you move the entire render loop to a Web Worker, keeping the main thread free — though the React integration story there is a bit more involved.

Layer your canvases. Instead of one canvas doing everything, use two absolutely-positioned canvases stacked via CSS z-index. A slow-moving background (updated every 4th frame) goes on the bottom; fast-moving foreground elements go on top. This halves your draw calls for the background layer effectively.

In practice, if you're looking at more than 5,000 particles or complex shader-style effects, you've crossed the threshold where WebGL makes sense. react-three-fiber gives you a React-idiomatic API over Three.js — the react-three-fiber guide covers the setup if you need to make that jump. For audio-reactive visuals specifically, the audio visualizer in React article shows how to drive canvas from Web Audio API data, which is another common escalation point.

One last performance note: ctx.save() and ctx.restore() are cheap, but not free. If you're calling them 1,000 times per frame (once per particle), the overhead adds up. Batch particles of the same color together and set fillStyle once per batch rather than once per particle — that alone can cut draw time by 30-40% for homogeneous particle fields.

FAQ

Does requestAnimationFrame work inside a React useEffect?

Yes, and it's the right place for it. Start the loop in useEffect, store the frame ID in a variable, and cancel it in the cleanup function. This prevents stale loops after unmount.

Why is my canvas blurry on retina displays?

Your canvas drawing resolution doesn't match the CSS display size. Multiply canvas.width and canvas.height by window.devicePixelRatio, then call ctx.scale(dpr, dpr) to fix it.

Can I use canvas animations behind glassmorphism effects?

Absolutely. Position the canvas absolutely, place a frosted glass div on top with backdrop-filter: blur(), and tune the opacity so the animation stays visible. The glassmorphism generator can help dial in the right values.

When should I use WebGL instead of canvas 2D for animations?

Once you're past roughly 2,000 alpha-blended moving elements at 60fps, canvas 2D starts struggling on mobile. Beyond that threshold, or if you need 3D, switch to WebGL via Three.js or react-three-fiber.

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

Read next

React Three Fiber: 3D Graphics in React Without WebGL Expertisereact-spring: Physics-Based Animations Without the ComplexityHTML Canvas Animations in React: Particles, Noise Fields, MoreConfetti in React 2026: canvas-confetti, tsParticles and Pure CSS