Image Trail Hero
Images spawn and fade along the cursor path as you move across the section. Falls idle after 2 s of inactivity.
cursorimage-trailinteractivedark
Preview
Interactive
Move your cursor. Watch it trail.
Images follow your mouse as you explore. Swap the array for your own shots.
Source Code
Copy the file below into your project. Requires framer-motion, tailwindcss, and next/font/google.
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
// ─── Hook ────────────────────────────────────────────────────────────────────
type TrailItem = {
id: number;
src: string;
x: number;
y: number;
rotate: number;
};
const IDLE_MS = 2000;
const REMOVE_MS = 850;
function useTrailEffect(images: string[]) {
const [trail, setTrail] = useState<TrailItem[]>([]);
const [isIdle, setIsIdle] = useState(true);
const sectionRef = useRef<HTMLElement>(null);
const lastPos = useRef({ x: 0, y: 0 });
const counter = useRef(0);
const imgIdx = useRef(0);
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const section = sectionRef.current;
if (!section || images.length === 0) return;
const resetIdle = () => {
setIsIdle(false);
if (idleTimer.current) clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(() => setIsIdle(true), IDLE_MS);
};
const onMove = (e: MouseEvent) => {
resetIdle();
const rect = section.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const threshold = Math.max(window.innerWidth / 26, 36);
if (Math.hypot(x - lastPos.current.x, y - lastPos.current.y) < threshold)
return;
lastPos.current = { x, y };
const id = ++counter.current;
const src = images[imgIdx.current % images.length];
imgIdx.current++;
setTrail((prev) => [
...prev.slice(-12),
{ id, src, x, y, rotate: (Math.random() - 0.5) * 28 },
]);
setTimeout(
() => setTrail((prev) => prev.filter((t) => t.id !== id)),
REMOVE_MS,
);
};
idleTimer.current = setTimeout(() => setIsIdle(true), IDLE_MS);
section.addEventListener("mousemove", onMove);
return () => {
section.removeEventListener("mousemove", onMove);
if (idleTimer.current) clearTimeout(idleTimer.current);
};
}, [images]);
return { trail, isIdle, sectionRef };
}
// ─── Component ───────────────────────────────────────────────────────────────
export interface ImageTrailHeroProps {
images: string[];
title: string;
subtitle?: string;
description?: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
theme?: "light" | "dark";
}
export default function ImageTrailHero({
images,
title,
subtitle,
description,
primaryButton,
secondaryButton,
theme = "dark",
}: ImageTrailHeroProps) {
const { trail, sectionRef } = useTrailEffect(images);
const isDark = theme === "dark";
return (
<section
ref={sectionRef}
className={`relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-20 ${
isDark ? "bg-zinc-950 text-white" : "bg-stone-50 text-zinc-900"
}`}
>
<AnimatePresence>
{trail.map((item) => (
<motion.div
key={item.id}
aria-hidden="true"
initial={{ opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.1 }}
transition={{ type: "spring", stiffness: 500, damping: 28 }}
style={{
left: item.x,
top: item.y,
rotate: item.rotate,
translateX: "-50%",
translateY: "-50%",
}}
className="pointer-events-none absolute h-36 w-36 overflow-hidden rounded-2xl shadow-2xl"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.src} alt="" className="h-full w-full object-cover" />
</motion.div>
))}
</AnimatePresence>
<div className="relative z-10 flex flex-col items-center text-center">
{subtitle && (
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className={`mb-4 text-sm font-semibold uppercase tracking-widest ${
isDark ? "text-zinc-400" : "text-zinc-500"
}`}
>
{subtitle}
</motion.p>
)}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="mb-6 max-w-3xl text-5xl font-bold leading-tight tracking-tight lg:text-6xl"
>
{title}
</motion.h1>
{description && (
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.15 }}
className={`mb-10 max-w-xl text-lg leading-relaxed ${
isDark ? "text-zinc-400" : "text-zinc-600"
}`}
>
{description}
</motion.p>
)}
{(primaryButton || secondaryButton) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="flex flex-col gap-3 sm:flex-row"
>
{primaryButton && (
<a
href={primaryButton.href}
className={`rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-80 ${
isDark ? "bg-white text-zinc-900" : "bg-zinc-900 text-white"
}`}
>
{primaryButton.text}
</a>
)}
{secondaryButton && (
<a
href={secondaryButton.href}
className={`rounded-full border px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-60 ${
isDark
? "border-zinc-700 text-zinc-300"
: "border-zinc-300 text-zinc-700"
}`}
>
{secondaryButton.text}
</a>
)}
</motion.div>
)}
</div>
</section>
);
}
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| images | string[] | Yes | — | Array of image URLs cycled through as the cursor moves |
| title | string | Yes | — | Main headline |
| subtitle | string | No | — | Small accent text above the title |
| description | string | No | — | Body copy below the title |
| primaryButton | { text: string; href: string } | No | — | Primary CTA button |
| secondaryButton | { text: string; href: string } | No | — | Secondary CTA button |
| theme | "light" | "dark" | No | "dark" | Color theme |