Motion
Image Cursor Trail
Images spawn behind the cursor as it moves, cycling through the set and clearing themselves out so at most ten are ever alive.
jswnth/ui
Move your cursor
import { ImageCursorTrail } from "@/components/ui/image-cursor-trail";
const images = Array.from(
{ length: 10 },
(_, index) => `https://picsum.photos/seed/trail-${index}/300/380`,
);
export default function ImageCursorTrailDemo() {
return (
<ImageCursorTrail
images={images}
className="aspect-16/10 rounded-card border border-line bg-elevated"
>
<p className="text-xs tracking-widest text-faint uppercase">jswnth/ui</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
Move your cursor
</h3>
</ImageCursorTrail>
);
}Installation
1. Install the dependencies.
terminal npm install motion clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/image-cursor-trail.tsx "use client"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; type TrailItem = { id: number; x: number; y: number; image: string; rotate: number; }; type ImageCursorTrailProps = { /** Cycled through in order as the cursor moves. */ images: string[]; /** Most images alive at once. */ max?: number; /** Pixels of cursor travel between spawns. */ distance?: number; /** Milliseconds an image stays before it leaves. */ life?: number; /** Width of each trail image, in pixels. */ size?: number; /** Sits under the trail — a heading, usually. */ children?: ReactNode; className?: string; }; export function ImageCursorTrail({ images, max = 10, distance = 70, life = 700, size = 150, children, className, }: ImageCursorTrailProps) { const [items, setItems] = useState<TrailItem[]>([]); const spawned = useRef(0); const lastPoint = useRef<{ x: number; y: number } | null>(null); const timers = useRef<number[]>([]); useEffect(() => { const pending = timers.current; return () => pending.forEach(window.clearTimeout); }, []); const handleMove = useCallback( (event: React.MouseEvent<HTMLDivElement>) => { const bounds = event.currentTarget.getBoundingClientRect(); const x = event.clientX - bounds.left; const y = event.clientY - bounds.top; const previous = lastPoint.current; if (previous && Math.hypot(x - previous.x, y - previous.y) < distance) return; lastPoint.current = { x, y }; const id = spawned.current++; const item: TrailItem = { id, x, y, image: images[id % images.length]!, // Deterministic tilt, so the trail reads as varied without randomness. rotate: ((id % 5) - 2) * 7, }; // Oldest images fall off the end once the trail is full. setItems((current) => [...current, item].slice(-max)); const timer = window.setTimeout(() => { setItems((current) => current.filter((entry) => entry.id !== id)); }, life); timers.current.push(timer); }, [distance, images, life, max], ); return ( <div onMouseMove={handleMove} onMouseLeave={() => { lastPoint.current = null; }} className={cn( "relative grid w-full place-items-center overflow-hidden", className, )} > <AnimatePresence> {items.map((item) => ( <motion.img key={item.id} src={item.image} alt="" aria-hidden draggable={false} initial={{ opacity: 0, scale: 0.55, rotate: 0 }} animate={{ opacity: 1, scale: 1, rotate: item.rotate }} exit={{ opacity: 0, scale: 0.72, y: "-64%" }} transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }} // Centring goes through motion's own x/y, since anything it // animates would otherwise overwrite a CSS transform. style={{ left: item.x, top: item.y, x: "-50%", y: "-50%", width: size, height: size * 1.25, zIndex: item.id, }} className="pointer-events-none absolute rounded-xl object-cover shadow-[0_18px_40px_-24px_rgb(0_0_0/0.6)]" /> ))} </AnimatePresence> {children ? ( <div className="pointer-events-none relative z-0 text-center">{children}</div> ) : null} </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images | string[] | — | Cycled through in order as the cursor moves. |
| max | number | 10 | Most images alive at once. |
| distance | number | 70 | Pixels of cursor travel between spawns. |
| life | number | 700 | Milliseconds an image stays before it leaves. |
| size | number | 150 | Width of each trail image, in pixels. |
| children | ReactNode | — | Sits under the trail — a heading, usually. |