Fluid Cursor Trail for React
Canvas particle trail that follows the cursor. Fixed overlay, customizable color, physics, and particle count.
Component
Move your cursor here - trail appears in this area only
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface FluidCursorTrailProps {
className?: string;
color?: string;
particleCount?: number;
particleSize?: number;
velocity?: number;
gravity?: number;
fadeSpeed?: number;
zIndex?: number;
bound?: boolean;
}
export function FluidCursorTrail({
className,
color = "#8b5cf6",
particleCount = 3,
particleSize = 4,
velocity = 4,
gravity = 0.2,
fadeSpeed = 0.02,
zIndex = 9999,
bound = false,
}: FluidCursorTrailProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<
{
x: number;
y: number;
vx: number;
vy: number;
life: number;
}[]
>([]);
useEffect(() => {
const canvas = canvasRef.current;
const container = bound ? containerRef.current : null;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
const resize = () => {
if (bound && container) {
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
} else {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
};
resize();
window.addEventListener("resize", resize);
let resizeObs: ResizeObserver | undefined;
if (bound && container) {
resizeObs = new ResizeObserver(resize);
resizeObs.observe(container);
}
const handleMouse = (e: MouseEvent) => {
let x: number;
let y: number;
if (bound && container) {
const rect = container.getBoundingClientRect();
if (
e.clientX < rect.left ||
e.clientX > rect.right ||
e.clientY < rect.top ||
e.clientY > rect.bottom
) {
return;
}
x = e.clientX - rect.left;
y = e.clientY - rect.top;
} else {
x = e.clientX;
y = e.clientY;
}
for (let i = 0; i < particleCount; i++) {
particlesRef.current.push({
life: 1,
vx: (Math.random() - 0.5) * velocity,
vy: (Math.random() - 0.5) * velocity,
x,
y,
});
}
};
window.addEventListener("mousemove", handleMouse);
let raf: number;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const next: typeof particlesRef.current = [];
for (const p of particlesRef.current) {
p.x += p.vx;
p.y += p.vy;
p.life -= fadeSpeed;
p.vy += gravity;
if (p.life > 0) {
next.push(p);
ctx.globalAlpha = p.life;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(p.x, p.y, particleSize, 0, Math.PI * 2);
ctx.fill();
}
}
particlesRef.current = next;
ctx.globalAlpha = 1;
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
return () => {
resizeObs?.disconnect();
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", handleMouse);
cancelAnimationFrame(raf);
};
}, [
bound,
color,
particleCount,
particleSize,
velocity,
gravity,
fadeSpeed,
]);
const canvas = (
<canvas
ref={canvasRef}
className={cn(
"pointer-events-none cursor-none",
bound
? "absolute inset-0 size-full"
: "fixed inset-0",
!bound && className,
)}
style={{ pointerEvents: "none", zIndex }}
title="Fluid cursor trail"
>
Decorative cursor trail
</canvas>
);
if (bound) {
return (
<div
ref={containerRef}
className={cn(
"absolute inset-0 overflow-hidden",
className,
)}
>
{canvas}
</div>
);
}
return canvas;
}Installation
1. Copy the component file
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface FluidCursorTrailProps {
className?: string;
color?: string;
particleCount?: number;
particleSize?: number;
velocity?: number;
gravity?: number;
fadeSpeed?: number;
zIndex?: number;
bound?: boolean;
}
export function FluidCursorTrail({
className,
color = "#8b5cf6",
particleCount = 3,
particleSize = 4,
velocity = 4,
gravity = 0.2,
fadeSpeed = 0.02,
zIndex = 9999,
bound = false,
}: FluidCursorTrailProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<
{
x: number;
y: number;
vx: number;
vy: number;
life: number;
}[]
>([]);
useEffect(() => {
const canvas = canvasRef.current;
const container = bound ? containerRef.current : null;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
return;
}
const resize = () => {
if (bound && container) {
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
} else {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
};
resize();
window.addEventListener("resize", resize);
let resizeObs: ResizeObserver | undefined;
if (bound && container) {
resizeObs = new ResizeObserver(resize);
resizeObs.observe(container);
}
const handleMouse = (e: MouseEvent) => {
let x: number;
let y: number;
if (bound && container) {
const rect = container.getBoundingClientRect();
if (
e.clientX < rect.left ||
e.clientX > rect.right ||
e.clientY < rect.top ||
e.clientY > rect.bottom
) {
return;
}
x = e.clientX - rect.left;
y = e.clientY - rect.top;
} else {
x = e.clientX;
y = e.clientY;
}
for (let i = 0; i < particleCount; i++) {
particlesRef.current.push({
life: 1,
vx: (Math.random() - 0.5) * velocity,
vy: (Math.random() - 0.5) * velocity,
x,
y,
});
}
};
window.addEventListener("mousemove", handleMouse);
let raf: number;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const next: typeof particlesRef.current = [];
for (const p of particlesRef.current) {
p.x += p.vx;
p.y += p.vy;
p.life -= fadeSpeed;
p.vy += gravity;
if (p.life > 0) {
next.push(p);
ctx.globalAlpha = p.life;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(p.x, p.y, particleSize, 0, Math.PI * 2);
ctx.fill();
}
}
particlesRef.current = next;
ctx.globalAlpha = 1;
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
return () => {
resizeObs?.disconnect();
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", handleMouse);
cancelAnimationFrame(raf);
};
}, [
bound,
color,
particleCount,
particleSize,
velocity,
gravity,
fadeSpeed,
]);
const canvas = (
<canvas
ref={canvasRef}
className={cn(
"pointer-events-none cursor-none",
bound
? "absolute inset-0 size-full"
: "fixed inset-0",
!bound && className,
)}
style={{ pointerEvents: "none", zIndex }}
title="Fluid cursor trail"
>
Decorative cursor trail
</canvas>
);
if (bound) {
return (
<div
ref={containerRef}
className={cn(
"absolute inset-0 overflow-hidden",
className,
)}
>
{canvas}
</div>
);
}
return canvas;
}2. Import and add to layout
import { FluidCursorTrail } from "@/components/fluid-cursor-trail";
<FluidCursorTrail />;Usage
Import
Add the FluidCursorTrail import.
import { FluidCursorTrail } from "@/components/fluid-cursor-trail";Use
Add to layout for site-wide effect.
<FluidCursorTrail />;Guidelines
- bound=false (default): full-screen fixed overlay. Add to root layout for site-wide effect.
- bound=true: trail limited to parent container. Parent needs position: relative; use absolute inset-0 on the wrapper.
- velocity: initial particle spread. gravity: downward acceleration. fadeSpeed: opacity decay per frame.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional CSS classes for the canvas. |
| color | string | "#8b5cf6" | Particle color (hex or CSS color). |
| particleCount | number | 3 | Particles spawned per mousemove. |
| particleSize | number | 4 | Particle radius in pixels. |
| velocity | number | 4 | Initial particle velocity spread. |
| gravity | number | 0.2 | Downward gravity applied each frame. |
| fadeSpeed | number | 0.02 | Opacity decrease per frame (fade speed). |
| zIndex | number | 9999 | z-index of the overlay. |
| bound | boolean | false | When true, trail is limited to the parent container instead of full screen. |
Accessibility
- Decorative visual effect with no interactive controls, no focusable elements, and no keyboard interaction to support.
- The canvas is pointer-events none and carries only a title attribute plus fallback text, so it adds no operable UI and is effectively invisible to keyboard and assistive technology.
- It does not check prefers-reduced-motion, so the animation runs unconditionally; it should be guarded with matchMedia('(prefers-reduced-motion: reduce)') to disable or reduce particles for users who prefer reduced motion.
- As a non-essential ambient layer it conveys no information, so the lack of ARIA roles or labels is acceptable, but motion-sensitive users currently have no built-in way to opt out.
Performance
- The effect renders into a Canvas 2D context, clearing and redrawing every particle with arc and fill each frame rather than animating CSS transform or opacity, so cost scales with particle count and is CPU rasterization rather than a cheap GPU-composited property.
- The render loop uses requestAnimationFrame and prunes dead particles into a fresh array each frame, which keeps the loop bounded but offers no throttling when particleCount or fadeSpeed produces many simultaneous particles.
- There is no will-change hint or GPU compositing of the canvas, and the canvas is sized to the full viewport or the bound container via resize handling.
- A ResizeObserver is used only in bound mode and the mousemove and resize listeners plus the rAF loop are all cleaned up on unmount, avoiding leaked listeners and timers.
- It has no IntersectionObserver or visibility gating, so the animation keeps running even when offscreen or when the user prefers reduced motion.
Examples
Basic
Add to layout for site-wide cursor trail.
Move your cursor here - trail appears in this area only
import { FluidCursorTrail } from "@/components/fluid-cursor-trail";
export default function Layout({ children }) {
return (
<>
{children}
<FluidCursorTrail />
</>
);
}Bound to container
Trail limited to container. Use bound with a relative parent.
Move your cursor here - trail appears in this area only
import { FluidCursorTrail } from "@/components/fluid-cursor-trail";
<div className="relative h-64">
<FluidCursorTrail bound />
<div className="relative flex h-full items-center justify-center">
Content
</div>
</div>;Full screen
Full-screen trail with custom color and particle settings.
Move your cursor here - trail appears in this area only
import { FluidCursorTrail } from "@/components/fluid-cursor-trail";
<FluidCursorTrail
color="#10b981"
particleCount={5}
particleSize={6}
/>;Physics & layering
Faster particles, stronger gravity, quicker fade, custom z-index.
Move your cursor here - trail appears in this area only
import { FluidCursorTrail } from "@/components/fluid-cursor-trail";
<FluidCursorTrail
color="#f43f5e"
velocity={8}
gravity={0.3}
fadeSpeed={0.03}
zIndex={50}
/>;Related reading
- Offloading Motion to the GPU — Rendering a smooth trail without dropping frames
- The Physics Behind Natural Motion — The lerp/easing that makes the trail feel fluid
Last updated on Jun 19