Image Trails
A cursor trail that drops tilted photo cards which slice apart and fly off as you move. React, TypeScript and Tailwind CSS, no dependencies.
ImageTrails.tsx
"use client";
// Cursor image trail: moving the pointer over the section drops tilted photo
// cards that pop in, then slice apart and fly off. Touch devices see a static stack.
// React + inline styles, no dependencies. Works with Tailwind v3 and v4.
import { useEffect, useRef } from "react";
type Props = {
heading?: string;
hint?: string;
images?: string[];
};
const img = (id: string) => `https://images.unsplash.com/photo-${id}?w=480&q=75&auto=format&fit=crop`;
const DEFAULT_IMAGES = [
img("1705860589032-a9c98cb3cfa8"),
img("1622179662825-3ae0a26f1b26"),
img("1604076913837-52ab5629fba9"),
img("1496365597860-f19cab8ecdaf"),
img("1504738165310-a8d01a22fbbd"),
img("1565382038303-8c62e88d119a"),
img("1555988776-c3f17e5cb789"),
img("1592762723096-ab8e59e76f06"),
];
const MAX_CARDS = 12; // cards on screen at once
const SPAWN_DIST = 80; // px the pointer travels between cards
const CARD_W = 160;
const CARD_H = 200;
const LIFE_MS = 1200;
const CHOP_DELAY = 400; // when a card starts slicing apart
const PIECES = 6;
const ACCENTS = ["#FF3D00", "#FFB300", "#E53935", "#1565C0", "#FFFFFF"];
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
type Card = { el: HTMLDivElement; timers: number[] };
function spawn(stage: HTMLElement, x: number, y: number, src: string, index: number): Card {
const tilt = rand(-18, 18);
const scale = rand(0.88, 1.08);
const el = document.createElement("div");
el.style.cssText = `position:absolute;left:${x - CARD_W / 2}px;top:${y - CARD_H / 2}px;width:${CARD_W}px;height:${CARD_H}px;pointer-events:none;overflow:hidden;border-radius:3px;transform:rotate(${tilt}deg) scale(0);transform-origin:center bottom;transition:transform .25s cubic-bezier(.34,1.56,.64,1),opacity .25s ease;`;
el.innerHTML = `<div style="position:absolute;inset:0;background:url(${src}) center/cover no-repeat"></div><div style="position:absolute;inset:0;background:${ACCENTS[index % ACCENTS.length]};mix-blend-mode:multiply;opacity:.35"></div>`;
stage.appendChild(el);
requestAnimationFrame(() => {
el.style.transform = `rotate(${tilt}deg) scale(${scale})`;
});
const chop = window.setTimeout(() => {
// Rebuild the card as vertical slices of the same photo, then send each flying
el.innerHTML = "";
for (let i = 0; i < PIECES; i++) {
const piece = document.createElement("div");
piece.style.cssText = `position:absolute;top:0;height:100%;left:${(i * 100) / PIECES}%;width:${100 / PIECES}%;background:url(${src}) ${(-i * CARD_W) / PIECES}px 0/${CARD_W}px ${CARD_H}px no-repeat;transition:transform ${280 + i * 30}ms cubic-bezier(.4,0,1,1) ${i * 28}ms,opacity 200ms ease ${i * 28}ms;`;
el.appendChild(piece);
requestAnimationFrame(() =>
requestAnimationFrame(() => {
piece.style.transform = `translate(${rand(-120, 120)}px,${rand(-160, -60)}px) rotate(${rand(-45, 45)}deg)`;
piece.style.opacity = "0";
}),
);
}
el.style.transition = `opacity ${LIFE_MS - CHOP_DELAY}ms ease`;
el.style.opacity = "0";
}, CHOP_DELAY);
const kill = window.setTimeout(() => el.remove(), LIFE_MS + 100);
return { el, timers: [chop, kill] };
}
export default function ImageTrails({
heading = "Move your cursor",
hint = "Every 80px leaves a photo behind",
images = DEFAULT_IMAGES,
}: Props) {
const stageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const stage = stageRef.current;
if (!stage || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cards: Card[] = [];
let last = { x: -9999, y: -9999 };
let count = 0;
const onMove = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
const rect = stage.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (Math.hypot(x - last.x, y - last.y) < SPAWN_DIST) return;
last = { x, y };
cards = cards.filter((c) => c.el.isConnected);
if (cards.length >= MAX_CARDS) {
const old = cards.shift()!;
old.timers.forEach(clearTimeout);
old.el.remove();
}
cards.push(spawn(stage, x, y, images[count % images.length], count));
count++;
};
stage.addEventListener("pointermove", onMove);
return () => {
stage.removeEventListener("pointermove", onMove);
cards.forEach((c) => {
c.timers.forEach(clearTimeout);
c.el.remove();
});
};
}, [images]);
return (
<section ref={stageRef} className="relative flex min-h-[600px] cursor-crosshair items-center justify-center overflow-hidden bg-neutral-950 px-6 font-sans antialiased">
<div className="pointer-events-none relative z-10 text-center mix-blend-difference">
<h2 className="text-[clamp(48px,9vw,140px)] font-black uppercase leading-[0.9] tracking-[-0.04em] text-white">{heading}</h2>
<p className="mt-6 font-mono text-xs uppercase tracking-[0.2em] text-white/60">{hint}</p>
</div>
</section>
);
}
How to use it
- Make sure your Next.js or React project has Tailwind CSS set up.
- Create
components/ImageTrails.tsxand paste the code. - Import it into a page and replace the placeholder text and images with your own.
More free components
All animation →Launch a full site instead
All-Access PREMIUM NEXTJS ASTRO
Orvaine - Luxury Equestrian & Horse Breeding Website TemplateOrvaine is a premium website template for stud farms, sport horse breeders and equestrian estates: h...
All-Access NEXTJS ASTRO PREMIUM
Maressa - Luxury Yacht Charter & Sales Website TemplateMaressa is a premium website template for yacht charter, brokerage and superyacht brands: 21 editabl...
All-Access PREMIUM NEXTJS ASTRO
Krishi – Agriculture & Farm Website TemplateKrishi is a premium website template for farms, agritech and food brands: 17 editable sections, cine...

Ready for a faster site that's easier to edit?
Book a free 30-minute call. We'll review your current site and send a fixed-price plan. No pressure, no pitch.
Book Free Call No commitment required





