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
| Prop | Type | Default | Description |
|---|---|---|---|
| images | string[] | Image URLs. The pool cycles through them, and the static row shows the first five. Required. | |
| spacing | number | 96 | Pointer 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.