DEV Community

Abdul Kabir Khan
Abdul Kabir Khan

Posted on

Building 60fps Canvas Particle & Drift Animations in Next.js 15

Interactive visual effects—such as ambient drift particles, soft glow repulsion, and floating background dynamics—can elevate web applications. However, handling continuous animations via heavy DOM nodes or unrestricted CSS keyframes often results in layout recalculations, dropped frames, and memory leaks.

Using an isolated HTML5 2D canvas with proper React lifecycle hooks provides a lightweight, 60fps rendering pipeline inside Next.js 15 App Router client components.


1. Setting Up the Next.js Client Component

Because canvas manipulation requires access to browser APIs (window, requestAnimationFrame, and the 2D rendering context), the component must run strictly on the client.

Create FloralDriftCanvas.tsx:

"use client";

import React, { useEffect, useRef } from "react";

interface Particle {
  x: number;
  y: number;
  size: number;
  baseSpeedX: number;
  baseSpeedY: number;
  speedX: number;
  speedY: number;
  opacity: number;
  color: string;
}

export const FloralDriftCanvas: React.FC = () => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    // Check for accessibility preferences
    const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (prefersReducedMotion) return;

    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let animationFrameId: number;
    let width = (canvas.width = window.innerWidth);
    let height = (canvas.height = window.innerHeight);

    const handleResize = () => {
      width = canvas.width = window.innerWidth;
      height = canvas.height = window.innerHeight;
    };

    window.addEventListener("resize", handleResize);

    // Particle pool setup
    const particleCount = Math.min(Math.floor(window.innerWidth / 20), 45);
    const colors = ["#E8B4B8", "#F4A261", "#E76F51", "#D4A373"];

    const particles: Particle[] = Array.from({ length: particleCount }, () => ({
      x: Math.random() * width,
      y: Math.random() * height,
      size: Math.random() * 3 + 1.5,
      baseSpeedX: (Math.random() - 0.5) * 0.4,
      baseSpeedY: Math.random() * 0.5 + 0.2,
      speedX: (Math.random() - 0.5) * 0.4,
      speedY: Math.random() * 0.5 + 0.2,
      opacity: Math.random() * 0.6 + 0.2,
      color: colors[Math.floor(Math.random() * colors.length)],
    }));

    // Animation Loop
    const render = () => {
      ctx.clearRect(0, 0, width, height);

      particles.forEach((p) => {
        p.x += p.speedX;
        p.y += p.speedY;

        // Wrap around boundaries
        if (p.x < 0) p.x = width;
        if (p.x > width) p.x = 0;
        if (p.y > height) p.y = 0;

        // Draw particle
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fillStyle = p.color;
        ctx.globalAlpha = p.opacity;
        ctx.shadowBlur = 8;
        ctx.shadowColor = p.color;
        ctx.fill();
      });

      animationFrameId = requestAnimationFrame(render);
    };

    render();

    // Teardown listener and loop on unmount
    return () => {
      window.removeEventListener("resize", handleResize);
      cancelAnimationFrame(animationFrameId);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      className="pointer-events-none fixed inset-0 z-0 h-full w-full opacity-70"
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

2. Preventing React Hydration & Memory Leaks

When rendering canvas dynamics in Next.js 15:

Dynamic Import with SSR Disabled: If including on server pages, load the canvas via next/dynamic with ssr: false.

Explicit Animation Loop Teardown: Using cancelAnimationFrame(animationFrameId) in the cleanup hook prevents background CPU drain during hot reloads or route switches.

Accessibility First: The prefers-reduced-motion media query check stops execution if users request lower motion.


Live Demo

Check out the live working project here: https://cafe-de-flora-gilt.vercel.app

Top comments (0)