← All primitives
ImageTrail
InteractionSpawns a trail of images along the cursor path on fast movement, each fading + scaling out. Fixed recycled pool, distance-throttled, transform/opacity only.
$
npx shadcn@latest add https://prism.icglabs.co/r/image-trail.jsonDependencies:gsap
View raw manifest →components/prism/ImageTrail.tsx
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
/**
* ImageTrail — spawns a trail of images along the cursor path on fast movement,
* each fading + scaling out. Uses a fixed recycled pool (no unbounded nodes),
* distance throttling, and transform/opacity only. No-ops on coarse pointers and
* under prefers-reduced-motion.
*/
export function ImageTrail({
images,
className = "",
threshold = 80,
itemSize = 140,
}: {
images: string[];
className?: string;
/** px the cursor must travel before spawning the next image */
threshold?: number;
itemSize?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (
window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
!window.matchMedia("(pointer: fine)").matches
) {
return;
}
// Build a recycled pool of <img> nodes (2x the images for overlap headroom).
const pool: HTMLImageElement[] = [];
const poolSize = Math.max(images.length * 2, 8);
for (let i = 0; i < poolSize; i++) {
const img = document.createElement("img");
img.src = images[i % images.length];
img.alt = "";
Object.assign(img.style, {
position: "absolute",
top: "0",
left: "0",
width: `${itemSize}px`,
height: `${itemSize}px`,
objectFit: "cover",
borderRadius: "12px",
opacity: "0",
pointerEvents: "none",
willChange: "transform, opacity",
} as Partial<CSSStyleDeclaration>);
el.appendChild(img);
pool.push(img);
}
let idx = 0;
let lastX = 0;
let lastY = 0;
let primed = false;
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
if (!primed) {
lastX = x;
lastY = y;
primed = true;
return;
}
if (Math.hypot(x - lastX, y - lastY) < threshold) return;
lastX = x;
lastY = y;
const node = pool[idx % pool.length];
idx++;
gsap.killTweensOf(node);
gsap.set(node, { x: x - itemSize / 2, y: y - itemSize / 2, opacity: 1, scale: 0.7, rotate: gsap.utils.random(-12, 12) });
gsap.to(node, { scale: 1, duration: 0.35, ease: "power2.out" });
gsap.to(node, { opacity: 0, duration: 0.8, delay: 0.25, ease: "power2.in" });
};
el.addEventListener("pointermove", onMove);
return () => {
el.removeEventListener("pointermove", onMove);
pool.forEach((n) => {
gsap.killTweensOf(n);
n.remove();
});
};
}, [images, threshold, itemSize]);
return <div ref={ref} className={className} style={{ position: "relative", overflow: "hidden" }} />;
}