Skip to content
goodtabs UI
BrowseBrowse components

Image trail

Small images appear behind a moving pointer and fade out, from a fixed recycled pool.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/image-trail.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/image-trail

A heading on a stage. Move the pointer through it; on touch it is a calm row of images.

Usage

import { ImageTrail } from "@/components/ui/image-trail"

<ImageTrail images={["/work/one.jpg", "/work/two.jpg"]} className="h-dvh">
  <h1>Selected work</h1>
</ImageTrail>

Props

PropTypeDefaultDescription
imagesstring[]Image URLs. The pool cycles through them, and the static row shows the first five. Required.
spacingnumber96Pointer travel between two images, in px.

Source

image-trail/image-trail.tsx
"use client";

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP);

const LIVE = "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";
const POOL = 16;
const ARRIVE = 0.5;
const HOLD = 0.1;
const FADE = 0.5;
/** Spawn no faster than this, so a slot is always faded out before it is recycled. */
const GAP_MS = ((ARRIVE + HOLD + FADE) * 1000) / POOL;

type ImageTrailProps = React.ComponentProps<"div"> & {
  /** Image URLs. The pool cycles through them, and the static row shows the first five. */
  images: string[];
  /** Pointer travel between two images, in px. */
  spacing?: number;
};

/**
 * A stage where small images appear behind a moving pointer and fade out, recycling a fixed pool. The
 * images are decoration. On touch and under reduced motion a calm static row takes their place, in CSS,
 * so there is nothing to swap and no flash. Children are the real content, on top.
 */
function ImageTrail({ images, spacing = 96, className, children, ...props }: ImageTrailProps) {
  const root = React.useRef<HTMLDivElement>(null);
  const pool = Array.from({ length: POOL }, (_, i) => images[i % images.length]);

  useGSAP(
    () => {
      const box = root.current;
      if (!box) return;
      const media = gsap.matchMedia();
      media.add(LIVE, () => {
        const slots = gsap.utils.toArray<HTMLElement>("[data-slot=image-trail-image]", box);
        gsap.set(slots, { xPercent: -50, yPercent: -50 });

        // One pass on load, along a shallow arc under the content, so the stage is never empty before the
        // first move. These stay until a real pointer arrives.
        const { width, height } = box.getBoundingClientRect();
        const arc = slots.slice(0, 7);
        arc.forEach((slot, i) => {
          const t = i / (arc.length - 1);
          gsap.set(slot, {
            x: width * (0.5 + (t - 0.5) * 0.78),
            y: height * (0.86 - 0.1 * Math.sin(Math.PI * t)),
            zIndex: i + 1,
          });
        });
        gsap.fromTo(
          arc,
          { opacity: 0, scale: 0.92 },
          { opacity: 1, scale: 1, duration: 0.6, ease: "power2.out", stagger: 0.06 },
        );

        let next = arc.length;
        let z = arc.length;
        let lastX = 0;
        let lastY = 0;
        let lastAt = 0;
        let resting = true;

        const spawn = (x: number, y: number) => {
          const slot = slots[next];
          next = (next + 1) % slots.length;
          gsap.killTweensOf(slot);
          z += 1;
          gsap.set(slot, { zIndex: z });
          gsap.fromTo(
            slot,
            { x: lastX, y: lastY, opacity: 0, scale: 0.92 },
            { x, y, opacity: 1, scale: 1, duration: ARRIVE, ease: "power3.out" },
          );
          gsap.to(slot, { opacity: 0, scale: 0.96, duration: FADE, delay: ARRIVE + HOLD, ease: "power2.in" });
        };

        const move = (event: PointerEvent) => {
          const rect = box.getBoundingClientRect();
          const x = event.clientX - rect.left;
          const y = event.clientY - rect.top;
          if (resting) {
            resting = false;
            lastX = x;
            lastY = y;
            gsap.killTweensOf(arc);
            gsap.to(arc, { opacity: 0, scale: 0.96, duration: FADE, ease: "power2.in", stagger: 0.03 });
            return;
          }
          if (Math.hypot(x - lastX, y - lastY) < spacing || event.timeStamp - lastAt < GAP_MS) return;
          spawn(x, y);
          lastX = x;
          lastY = y;
          lastAt = event.timeStamp;
        };

        box.addEventListener("pointermove", move, { passive: true });
        return () => box.removeEventListener("pointermove", move);
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [spacing], revertOnUpdate: true },
  );

  return (
    <div
      ref={root}
      data-slot="image-trail"
      className={cn("relative isolate flex flex-col items-center justify-center overflow-hidden", className)}
      {...props}
    >
      <div className="relative z-10 flex w-full flex-col items-center">{children}</div>
      <div
        aria-hidden="true"
        className="pointer-events-none relative z-10 mt-10 grid w-full max-w-2xl grid-cols-5 gap-2 [@media(hover:hover)_and_(pointer:fine)]:motion-safe:hidden"
      >
        {images.slice(0, 5).map((src) => (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            key={src}
            src={src}
            alt=""
            draggable={false}
            className="aspect-[3/2] w-full rounded-sm border object-cover"
          />
        ))}
      </div>
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 z-0 hidden [@media(hover:hover)_and_(pointer:fine)]:motion-safe:block"
      >
        {pool.map((src, i) => (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            key={i}
            data-slot="image-trail-image"
            src={src}
            alt=""
            draggable={false}
            className="absolute top-0 left-0 aspect-[3/2] w-[clamp(5.5rem,14vw,10rem)] rounded-sm border object-cover opacity-0 will-change-transform"
          />
        ))}
      </div>
    </div>
  );
}

export { ImageTrail };

For AI agents

Hand your agent this page, the markdown at /components/image-trail/prompt.md, or the Copy AI prompt button above.

When to use

  • A portfolio or studio hero with plenty of imagery to show
  • A closing section that rewards moving around the page
  • Once on a page, as the part people play with

When not to use

  • Behind text people need to read for long: the images pass under it
  • Images that carry meaning, because they are hidden from screen readers
  • Dense pages, where the trail fights the content

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • The whole image layer is aria-hidden and pointer-events none. Put the real content in children
  • Give it a height with className. It clips to its own box and listens only inside it
  • Touch and reduced motion get the static row from CSS, so no script runs and nothing flashes

The maths

  • An image lives for 1.1s, so a pool of 16 can spawn every 1100 / 16 = 69ms and never recycle a visible slot.
  • Each image starts at the last spawn point and glides to the pointer, so the trail reads as one path.