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"
/>
);
};
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)