Fluid Cursor Trail
Canvas particle trail that follows the cursor. Fixed overlay, customizable color, physics, and particle count.
Last updated Mar 5, 2026
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
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/fluid-cursor-trail.json"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. |
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}
/>;