A dark portfolio hero for a photographer with a small nav, a large centred serif headline and a print shop button. As the pointer travels, picture cards appear one after another along its path. Each card starts slightly behind the pointer, glides to it with a soft tilt, holds, then shrinks and fades. The headline uses a difference blend, so it stays readable when a bright card passes underneath. The six cards are drawn with gradients and shapes, ready to be replaced by real images. Without input a demo pointer sweeps a slow loop. On phones the cards are smaller and follow a dragging finger.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/image/image-trail)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import { useInView, useReducedMotion } from "framer-motion";
// The cards that appear along the trail. Swap each block for an <img> to use real photographs.
const prints = [
{ name: "Low sun, Pier Road", sky: "linear-gradient(180deg,#ffb26b 0%,#ff5e62 55%,#4a1942 100%)", orb: "#fff3c4", orbAt: [62, 46], ground: "#2a0f33" },
{ name: "Lido, 7am", sky: "linear-gradient(180deg,#bfe9ff 0%,#6fb1fc 60%,#4364f7 100%)", orb: "#ffffff", orbAt: [28, 24], ground: "#0b3a8f" },
{ name: "Allotment in June", sky: "linear-gradient(180deg,#fdfbb8 0%,#b8e986 60%,#3f9d5a 100%)", orb: "#ff8a3d", orbAt: [70, 22], ground: "#1f5c3a" },
{ name: "Night bus", sky: "linear-gradient(180deg,#0f1b3d 0%,#3b2a6b 60%,#c2417a 100%)", orb: "#ffe082", orbAt: [34, 30], ground: "#0a0f24" },
{ name: "Washing line", sky: "linear-gradient(180deg,#ffe3ec 0%,#ffb3c7 55%,#ff7aa2 100%)", orb: "#ffffff", orbAt: [50, 30], ground: "#b8336a" },
{ name: "Quarry pool", sky: "linear-gradient(180deg,#d7fff1 0%,#5eead4 55%,#0f766e 100%)", orb: "#f8fafc", orbAt: [74, 34], ground: "#134e4a" },
];
const THRESHOLD = 95; // pixels the pointer must travel before the next card appears
const LIFE_MS = 1300; // how long a card stays on screen
const POOL = prints.length * 2; // cards kept in the DOM and reused
const IDLE_MS = 1500; // after this long without input, the demo pointer takes over
export default function ImageTrailOfCardsFollowingTheCursor() {
const reduce = useReducedMotion();
const sectionRef = useRef<HTMLElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const inView = useInView(sectionRef, { amount: 0.2 });
useEffect(() => {
const section = sectionRef.current;
if (!section || reduce || !inView) return;
const cards = cardRefs.current.filter((c): c is HTMLDivElement => !!c);
let next = 0;
let z = 1;
const lastSpawn = { x: -9999, y: -9999 };
const lagged = { x: 0, y: 0, set: false }; // a smoothed pointer: cards start here and glide to the real one
const move = (x: number, y: number) => {
if (!lagged.set) {
lagged.x = x;
lagged.y = y;
lagged.set = true;
}
lagged.x += (x - lagged.x) * 0.3;
lagged.y += (y - lagged.y) * 0.3;
if (Math.hypot(x - lastSpawn.x, y - lastSpawn.y) < THRESHOLD) return;
lastSpawn.x = x;
lastSpawn.y = y;
const card = cards[next];
next = (next + 1) % cards.length;
const hw = card.offsetWidth / 2;
const hh = card.offsetHeight / 2;
const tilt = ((next * 37) % 13) - 6;
card.style.zIndex = String(z++);
card.getAnimations().forEach((a) => a.cancel());
card.animate({ translate: [`${lagged.x - hw}px ${lagged.y - hh}px`, `${x - hw}px ${y - hh}px`] }, { duration: 900, easing: "cubic-bezier(0.16, 1, 0.3, 1)", fill: "forwards" });
card.animate(
{ scale: [0.2, 1, 1, 0.6], opacity: [0, 1, 1, 0], rotate: [`${tilt * 2}deg`, `${tilt}deg`, `${tilt}deg`, `${tilt}deg`], offset: [0, 0.14, 0.72, 1] },
{ duration: LIFE_MS, easing: "ease-out", fill: "forwards" }
);
};
let realAt = -Infinity;
const onMove = (e: PointerEvent) => {
const r = section.getBoundingClientRect();
realAt = performance.now();
move(e.clientX - r.left, e.clientY - r.top);
};
section.addEventListener("pointermove", onMove);
section.addEventListener("pointerdown", onMove);
// Demo pointer: sweeps a slow loop so the trail is visible without a mouse.
let raf = 0;
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
if (document.hidden || now - realAt < IDLE_MS) return;
const t = now / 1000;
move(section.clientWidth * (0.5 + 0.4 * Math.sin(t * 0.85)), section.clientHeight * (0.52 + 0.3 * Math.sin(t * 1.7 + 1)));
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
section.removeEventListener("pointermove", onMove);
section.removeEventListener("pointerdown", onMove);
cards.forEach((c) => c.getAnimations().forEach((a) => a.cancel()));
};
}, [reduce, inView]);
return (
<section ref={sectionRef} className="relative flex h-[680px] flex-col overflow-hidden bg-[#141210] px-6 py-8 text-stone-100">
{/* Trail layer */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{Array.from({ length: POOL }, (_, i) => {
const p = prints[i % prints.length];
return (
<div
key={i}
ref={(el) => {
cardRefs.current[i] = el;
}}
className="absolute left-0 top-0 h-40 w-32 overflow-hidden rounded-md opacity-0 shadow-2xl shadow-black/60 will-change-transform sm:h-56 sm:w-44"
style={{ background: p.sky }}
>
<span className="absolute h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full sm:h-14 sm:w-14" style={{ left: `${p.orbAt[0]}%`, top: `${p.orbAt[1]}%`, background: p.orb }} />
<span className="absolute -bottom-10 -left-6 h-24 w-[130%] rounded-[50%]" style={{ background: p.ground }} />
<span className="absolute bottom-2 left-2 font-mono text-[10px] uppercase tracking-wider text-white/90">{p.name}</span>
</div>
);
})}
</div>
<div className="relative z-10 mx-auto flex w-full max-w-6xl items-center justify-between text-sm mix-blend-difference">
<a href="#" className="font-serif text-xl italic focus:outline-none focus-visible:ring-2 focus-visible:ring-white">
Ines Calloway
</a>
<nav aria-label="Portfolio">
<ul className="flex gap-6 font-medium">
{["Work", "Prints", "About"].map((l) => (
<li key={l}>
<a href="#" className="inline-flex h-10 items-center underline-offset-4 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white">
{l}
</a>
</li>
))}
</ul>
</nav>
</div>
<div className="pointer-events-none relative z-10 mx-auto flex w-full max-w-5xl flex-1 flex-col items-center justify-center text-center mix-blend-difference">
<p className="font-mono text-xs uppercase tracking-[0.3em] text-stone-300">Selected work, 2019 to now</p>
<h2 className="mt-6 font-serif text-5xl leading-[1.02] tracking-tight text-white sm:text-7xl lg:text-8xl">
Photographs of ordinary <em>afternoons.</em>
</h2>
</div>
<div className="relative z-10 mx-auto flex w-full max-w-6xl flex-wrap items-end justify-between gap-4">
<p className="max-w-xs text-sm leading-relaxed text-stone-300 mix-blend-difference">Move across the page to leaf through the archive. Every frame is available as a signed print.</p>
<a href="#" className="inline-flex h-12 items-center rounded-full bg-stone-100 px-7 text-sm font-semibold text-stone-950 transition hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#141210]">
Browse the print shop
</a>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/image-trail-of-cards-following-the-cursor.json
Each card runs two Web Animations at once. One moves the `translate` property from a smoothed pointer position to the real one, which gives the glide. The other runs `scale`, `opacity` and `rotate` through appear, hold and leave. Using the individual transform properties lets the two timelines have different durations and easing.
Creating and removing nodes on every pointer step causes layout work and garbage. A pool of twelve elements is reused in order: the oldest card is cancelled and restarted at the new position.
A counter is written to `z-index` each time a card is spawned, so the newest is always in front. Count down instead of up if you want older cards to stay on top.
Only for `useInView` and `useReducedMotion`. The trail itself uses the browser's `element.animate`, so you can swap those hooks for an IntersectionObserver and a media query.
All current versions of Chrome, Edge, Firefox and Safari. For very old browsers, combine them into one `transform` keyframe list instead.
Adapted from Image Trail in Fancy Components, used under its open-source licence. Licences and credits
Related: Pixel Trail Cursor Effect over Hero Content, Spotlight Cards with Pointer Light, Coverflow Gallery with 3D Perspective and Autoplay.
More cursor effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.