Cursor Image Trail
A trail of images that follows the cursor with fade, scale, and rotation animations powered by Motion.
Installation
File Structure
Usage
import { CursorImageTrail } from "@/components/unlumen-ui/cursor-image-trail";
const IMAGES = [
"https://example.com/photo-1.jpg",
"https://example.com/photo-2.jpg",
"https://example.com/photo-3.jpg",
];
<CursorImageTrail images={IMAGES} className="h-[480px] w-full bg-neutral-950">
<p className="text-white/30 select-none">Move your cursor</p>
</CursorImageTrail>;API Reference
CursorImageTrail
imagesstring[]—Array of image URLs to cycle through in the trail.
imageSize?number120Width and height of each trail image in pixels.
trailLength?number8Maximum number of images simultaneously visible in the trail.
spawnDistance?number80Minimum cursor travel in pixels before a new image is spawned.
rotationRange?number20Maximum random rotation (±degrees) applied to each image.
containerRef?React.RefObject<HTMLElement>—Optional ref to a container element. Defaults to tracking the whole window.
className?string—Additional className for the outer wrapper div.
children?React.ReactNode—Optional content rendered inside the trail container.
Notes
- Images are cycled in order from the
imagesarray, wrapping back to the start. - Each image is centered on the spawn point using CSS
transform: translate(-50%, -50%). - Older trail images are progressively smaller and more transparent — the newest image is always fully opaque at full scale.
- Add
cursor-noneto the container className to hide the default OS cursor for full effect. - Pass
containerRefto confine tracking to a specific element rather than the whole window.
Credits
Built by Léo.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
export interface CursorImageTrailProps {
items: React.ReactNode[];
/** Size of each trail item in px. @default 120 */
itemSize?: number;
/** Max simultaneous items in the trail. @default 8 */
trailLength?: number;
/** Minimum cursor travel (px) before spawning a new item. @default 80 */
spawnDistance?: number;
/** Max random rotation applied to each item in degrees. @default 20 */
rotationRange?: number;
/** Render target — defaults to the whole window. */
containerRef?: React.RefObject<HTMLElement>;
className?: string;
children?: React.ReactNode;
}
interface TrailItem {
id: number;
x: number;
y: number;
rotation: number;
itemIndex: number;
}
let _id = 0;
const nextId = () => ++_id;
export function CursorImageTrail({
items,
itemSize = 120,
trailLength = 8,
spawnDistance = 80,
rotationRange = 20,
containerRef,
className,
children,
}: CursorImageTrailProps) {
const [trail, setTrail] = React.useState<TrailItem[]>([]);
const lastPos = React.useRef<{ x: number; y: number } | null>(null);
const itemCounter = React.useRef(0);
const containerElRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const el = containerRef?.current ?? containerElRef.current ?? window;
const onLeave = () => setTrail([]);
const onMove = (e: Event) => {
const mouseEvent = e as MouseEvent;
const rect =
containerRef?.current?.getBoundingClientRect() ??
containerElRef.current?.getBoundingClientRect();
const x = rect ? mouseEvent.clientX - rect.left : mouseEvent.clientX;
const y = rect ? mouseEvent.clientY - rect.top : mouseEvent.clientY;
if (lastPos.current) {
const dx = x - lastPos.current.x;
const dy = y - lastPos.current.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < spawnDistance) return;
}
lastPos.current = { x, y };
const rotation = (Math.random() * 2 - 1) * rotationRange;
const itemIndex = itemCounter.current % items.length;
itemCounter.current += 1;
setTrail((prev) => {
const next = [...prev, { id: nextId(), x, y, rotation, itemIndex }];
return next.slice(-trailLength);
});
};
el.addEventListener("mousemove", onMove);
el.addEventListener("mouseleave", onLeave);
return () => {
el.removeEventListener("mousemove", onMove);
el.removeEventListener("mouseleave", onLeave);
};
}, [items, spawnDistance, rotationRange, trailLength, containerRef]);
const total = trail.length;
return (
<div
ref={containerElRef}
className={cn("relative overflow-hidden", className)}
>
{children}
<AnimatePresence>
{trail.map((item, i) => {
const age = total - 1 - i;
const scale = 0.6 + 0.4 * (1 - age / trailLength);
return (
<motion.div
key={item.id}
className="pointer-events-none absolute select-none"
style={{
left: item.x,
top: item.y,
width: itemSize,
x: "-50%",
y: "-50%",
zIndex: i,
}}
initial={{
opacity: 0,
scale: 0.5,
rotate: item.rotation * 1.5,
}}
animate={{
opacity: 1,
scale,
rotate: item.rotation,
}}
exit={{
opacity: 0,
scale: 0.3,
rotate: item.rotation * 0.5,
filter: "blur(4px)",
}}
transition={{
duration: 0.4,
ease: [0.23, 1, 0.32, 1],
}}
>
<div className="w-full [&>svg]:h-auto [&>svg]:w-full [&>img]:h-auto [&>img]:w-full">
{items[item.itemIndex]}
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}