Prism
← All primitives

ImageTrail

Interaction

Spawns 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.json
Dependencies: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" }} />;
}
Live demo — read-only. Every section is a real, copyable primitive.