Image Trail

Cards spawn along the cursor path at fixed intervals, pop in with a tilt and fall away — a portfolio you leaf through by moving.

  • Cursors & Interactions
  • Photography
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (Web Animations API). Touch spawns along drags; off with reduced motion.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/image-trail.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/interactions/image-trail.tsx
1/**
2 * ImageTrail — as the pointer travels across an area, image cards spawn at
3 * fixed distance intervals along its path, pop in with a scale + rotation,
4 * hold briefly, then fall away and fade. The number of live cards is capped
5 * so long gestures never pile up DOM.
6 *
7 * Props
8 * - items: ReactNode[] Card contents (images, <img>, SVG, gradients…). Cycled in order.
9 * - spacing?: number Pointer travel in px between spawns. Default 90.
10 * - max?: number Max cards alive at once. Default 12.
11 * - life?: number ms a card lives. Default 1100.
12 * - size?: [number, number] Card size [w, h] in px. Default [220, 280] (scaled to 0.62 under 640px).
13 * - rotate?: number Max random tilt in degrees. Default 10.
14 * - children Content of the area (e.g. a headline); stays above or below via `layer`.
15 * - layer?: "under" | "over" Cards below or above children. Default "under".
16 * - className / style
17 * CSS vars: --fd-it-radius (card radius, default 14px), --fd-it-shadow.
18 *
19 * Performance: cards are a pooled set of absolutely positioned elements,
20 * animated with the Web Animations API (transform/opacity only). Pointer
21 * moves are coalesced into one rAF. Nothing runs while the pointer is idle.
22 * Touch: spawns along drags. Reduced motion: no trail (the area still works).
23 * Accessibility: the cards are decorative (aria-hidden); put real content in children.
24 * Browser support: all evergreen browsers (Web Animations API).
25 */
26import { useEffect, useId, useRef } from "react";
27import type { CSSProperties, ReactNode } from "react";
28
29export interface ImageTrailProps {
30 items: ReactNode[];
31 spacing?: number;
32 max?: number;
33 life?: number;
34 size?: [number, number];
35 rotate?: number;
36 layer?: "under" | "over";
37 children?: ReactNode;
38 className?: string;
39 style?: CSSProperties;
40}
41
42export function ImageTrail({ items, spacing = 90, max = 12, life = 1100, size = [220, 280], rotate = 10, layer = "under", children, className, style }: ImageTrailProps) {
43 const root = useRef<HTMLDivElement>(null);
44 const pool = useRef<(HTMLDivElement | null)[]>([]);
45
46 useEffect(() => {
47 const el = root.current;
48 if (!el) return;
49 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
50 const cards = pool.current.filter(Boolean) as HTMLDivElement[];
51 if (!cards.length) return;
52 let lastX = NaN;
53 let lastY = NaN;
54 let acc = 0;
55 let next = 0;
56 let z = 1;
57 let pending: { x: number; y: number } | null = null;
58 let raf = 0;
59 const alive: { card: HTMLDivElement; anim: Animation }[] = [];
60
61 const spawn = (x: number, y: number, vx: number, vy: number) => {
62 const card = cards[next % cards.length];
63 const content = next % items.length;
64 next++;
65 // only show the slot for this content
66 card.dataset.i = String(content);
67 const k = el.clientWidth < 640 ? 0.62 : 1;
68 const w = size[0] * k;
69 const h = size[1] * k;
70 card.style.width = `${w}px`;
71 card.style.height = `${h}px`;
72 card.style.zIndex = String(z++);
73 const rot = (Math.random() * 2 - 1) * rotate;
74 const drift = Math.min(1, Math.hypot(vx, vy) / 40);
75 const dx = vx * 1.6 * drift;
76 const dy = vy * 1.6 * drift + 28;
77 const x0 = x - w / 2;
78 const y0 = y - h / 2;
79 const old = alive.findIndex((a) => a.card === card);
80 if (old >= 0) {
81 alive[old].anim.cancel();
82 alive.splice(old, 1);
83 }
84 const anim = card.animate(
85 [
86 { transform: `translate3d(${x0}px,${y0}px,0) scale(.35) rotate(${rot * 2}deg)`, opacity: 0, offset: 0 },
87 { transform: `translate3d(${x0}px,${y0}px,0) scale(1.04) rotate(${rot}deg)`, opacity: 1, offset: 0.16, easing: "cubic-bezier(.22,1,.36,1)" },
88 { transform: `translate3d(${x0}px,${y0}px,0) scale(1) rotate(${rot}deg)`, opacity: 1, offset: 0.5, easing: "cubic-bezier(.65,0,.35,1)" },
89 { transform: `translate3d(${x0 + dx}px,${y0 + dy}px,0) scale(.82) rotate(${rot * 1.4}deg)`, opacity: 0, offset: 1 },
90 ],
91 { duration: life, easing: "linear", fill: "both" },
92 );
93 alive.push({ card, anim });
94 anim.onfinish = () => {
95 const i = alive.findIndex((a) => a.anim === anim);
96 if (i >= 0) alive.splice(i, 1);
97 };
98 // queue limit: fade out the oldest early
99 while (alive.length > max) {
100 const o = alive.shift()!;
101 o.anim.pause();
102 const fade = o.card.animate([{ opacity: getComputedStyle(o.card).opacity }, { opacity: 0 }], { duration: 160, fill: "forwards" });
103 fade.onfinish = () => {
104 o.anim.cancel();
105 fade.cancel();
106 };
107 }
108 };
109
110 const flush = () => {
111 raf = 0;
112 if (!pending) return;
113 const r = el.getBoundingClientRect();
114 const x = pending.x - r.left;
115 const y = pending.y - r.top;
116 pending = null;
117 if (Number.isNaN(lastX)) {
118 lastX = x;
119 lastY = y;
120 return;
121 }
122 const dx = x - lastX;
123 const dy = y - lastY;
124 const d = Math.hypot(dx, dy);
125 acc += d;
126 // spawn at exact distance intervals along the segment
127 let travelled = d - (acc - spacing);
128 while (acc >= spacing) {
129 const t = d ? Math.min(1, Math.max(0, travelled / d)) : 1;
130 spawn(lastX + dx * t, lastY + dy * t, dx, dy);
131 acc -= spacing;
132 travelled += spacing;
133 }
134 lastX = x;
135 lastY = y;
136 };
137 const onMove = (e: PointerEvent) => {
138 if (e.pointerType === "touch" && e.buttons === 0) return;
139 pending = { x: e.clientX, y: e.clientY };
140 if (!raf) raf = requestAnimationFrame(flush);
141 };
142 const onLeave = () => {
143 lastX = lastY = NaN;
144 acc = 0;
145 };
146 el.addEventListener("pointermove", onMove, { passive: true });
147 el.addEventListener("pointerleave", onLeave);
148 el.addEventListener("pointercancel", onLeave);
149 return () => {
150 el.removeEventListener("pointermove", onMove);
151 el.removeEventListener("pointerleave", onLeave);
152 el.removeEventListener("pointercancel", onLeave);
153 cancelAnimationFrame(raf);
154 alive.forEach((a) => a.anim.cancel());
155 };
156 }, [items.length, spacing, max, life, size[0], size[1], rotate]); // eslint-disable-line react-hooks/exhaustive-deps
157
158 const slots = Math.max(4, max + 2);
159 return (
160 <div ref={root} className={`fd-it${className ? " " + className : ""}`} data-layer={layer} style={style}>
161 <style>{css}</style>
162 <div className="fd-it-cards" aria-hidden="true">
163 {Array.from({ length: slots }, (_, s) => (
164 <div key={s} className="fd-it-card" ref={(n) => { pool.current[s] = n; }}>
165 {items.map((it, i) => (
166 <div key={i} className="fd-it-slot" data-slot={i}>{it}</div>
167 ))}
168 </div>
169 ))}
170 </div>
171 <div className="fd-it-content">{children}</div>
172 </div>
173 );
174}
175
176const css = `
177.fd-it,.fd-it *,.fd-it *::before,.fd-it *::after{box-sizing:border-box}
178.fd-it{--fd-it-radius:14px;--fd-it-shadow:0 24px 48px -16px rgba(0,0,0,.45),0 2px 6px rgba(0,0,0,.18);
179 position:relative;display:block;margin:0;padding:0;overflow:hidden;isolation:isolate;touch-action:pan-y}
180.fd-it-cards{position:absolute;inset:0;margin:0;padding:0;pointer-events:none;z-index:0}
181.fd-it[data-layer="over"] .fd-it-cards{z-index:2}
182.fd-it-content{position:relative;z-index:1;margin:0;padding:0}
183.fd-it-card{position:absolute;left:0;top:0;margin:0;padding:0;opacity:0;border-radius:var(--fd-it-radius);overflow:hidden;box-shadow:var(--fd-it-shadow);will-change:transform,opacity;
184 transform:translate3d(-9999px,0,0)}
185.fd-it-slot{display:none;position:absolute;inset:0;margin:0;padding:0}
186${Array.from({ length: 32 }, (_, i) => `.fd-it-card[data-i="${i}"] .fd-it-slot[data-slot="${i}"]`).join(",")}{display:block}
187.fd-it-slot > *{display:block;width:100%;height:100%;margin:0;object-fit:cover}
188`;
189
190/* ───────────────────────── procedural photo cards ───────────────────────── */
191
192type Scene = { sky: [string, string]; sun: string; hills: [string, string, string]; kind: "dunes" | "sea" | "peaks" | "city" | "bloom" | "portrait"; caption: string };
193
194const SCENES: Scene[] = [
195 { sky: ["#FFB88A", "#F6E3C7"], sun: "#FFF4D6", hills: ["#D9825B", "#B85F3E", "#7E3B27"], kind: "dunes", caption: "Erg Chebbi, 06:12" },
196 { sky: ["#0D2541", "#3D6D9A"], sun: "#F3E7C1", hills: ["#163C5E", "#0E2A44", "#081A2C"], kind: "sea", caption: "Faroe, blue hour" },
197 { sky: ["#DCE7EE", "#9CB7C9"], sun: "#FFFFFF", hills: ["#6E8BA0", "#465F72", "#253645"], kind: "peaks", caption: "Dolomites, −8°C" },
198 { sky: ["#1A1030", "#5B2A6E"], sun: "#FF6FA8", hills: ["#2B1745", "#1B0F2D", "#0D0718"], kind: "city", caption: "Shinjuku, 23:40" },
199 { sky: ["#F4EDE2", "#E6D3BC"], sun: "#C2412D", hills: ["#E2B49A", "#C77D5E", "#8E4A32"], kind: "bloom", caption: "Studio, still life 04" },
200 { sky: ["#232A22", "#4C5A44"], sun: "#E8D9B0", hills: ["#2E3A2A", "#1E271C", "#111710"], kind: "portrait", caption: "Ines, Lisbon" },
201 { sky: ["#F7C6C7", "#FCE8D8"], sun: "#FF8E72", hills: ["#E6A1A3", "#C77B86", "#8F5367"], kind: "sea", caption: "Algarve, 19:58" },
202 { sky: ["#0E1B14", "#2C4B3A"], sun: "#D6F5C9", hills: ["#1D3328", "#12241B", "#08130D"], kind: "peaks", caption: "Lofoten, aurora" },
203];
204
205function PhotoCard({ s, i }: { s: Scene; i: number }) {
206 // every pooled copy gets its own ids (useId per render position)
207 const g = `fd-it${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
208 return (
209 <svg viewBox="0 0 220 280" preserveAspectRatio="xMidYMid slice" style={{ display: "block", width: "100%", height: "100%" }}>
210 <defs>
211 <linearGradient id={`${g}s`} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={s.sky[0]} /><stop offset="1" stopColor={s.sky[1]} /></linearGradient>
212 <radialGradient id={`${g}l`} cx=".7" cy=".3" r=".8"><stop offset="0" stopColor="#fff" stopOpacity=".28" /><stop offset="1" stopColor="#fff" stopOpacity="0" /></radialGradient>
213 <filter id={`${g}n`}><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" seed={i + 3} /><feColorMatrix values="0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .09 0" /></filter>
214 </defs>
215 <rect width="220" height="280" fill={`url(#${g}s)`} />
216 {s.kind === "dunes" && (
217 <>
218 <circle cx="150" cy="96" r="30" fill={s.sun} />
219 <path d="M0 180 C60 150 100 170 140 160 S200 140 220 150 V280 H0Z" fill={s.hills[0]} />
220 <path d="M0 210 C50 190 90 215 150 196 S210 200 220 205 V280 H0Z" fill={s.hills[1]} />
221 <path d="M0 246 C70 226 120 250 220 232 V280 H0Z" fill={s.hills[2]} />
222 </>
223 )}
224 {s.kind === "sea" && (
225 <>
226 <circle cx="110" cy="150" r="34" fill={s.sun} />
227 <rect y="160" width="220" height="120" fill={s.hills[0]} />
228 {Array.from({ length: 9 }, (_, k) => (
229 <rect key={k} x={110 - 40 + k * 2} y={168 + k * 11} width={80 - k * 4} height="2.5" rx="1.2" fill={s.sun} opacity={0.75 - k * 0.07} />
230 ))}
231 <path d="M0 230 H220 V280 H0Z" fill={s.hills[2]} opacity=".55" />
232 </>
233 )}
234 {s.kind === "peaks" && (
235 <>
236 <circle cx="60" cy="70" r="16" fill={s.sun} opacity=".9" />
237 <path d="M-10 200 L60 92 L96 140 L132 80 L230 210 V280 H-10Z" fill={s.hills[0]} />
238 <path d="M60 92 L74 114 L62 110 L52 122 Z M132 80 L150 106 L136 100 L122 112Z" fill="#fff" opacity=".85" />
239 <path d="M-10 230 L50 176 L110 222 L170 168 L230 214 V280 H-10Z" fill={s.hills[1]} />
240 <path d="M0 260 C70 244 140 262 220 248 V280 H0Z" fill={s.hills[2]} />
241 </>
242 )}
243 {s.kind === "city" && (
244 <>
245 <circle cx="160" cy="70" r="22" fill={s.sun} opacity=".85" />
246 {[18, 46, 70, 96, 128, 152, 182].map((x, k) => (
247 <g key={k}>
248 <rect x={x} y={120 + ((k * 37) % 70)} width={22 + (k % 3) * 6} height="200" fill={s.hills[k % 2]} />
249 {Array.from({ length: 6 }, (_, r) => (
250 <rect key={r} x={x + 4} y={132 + ((k * 37) % 70) + r * 16} width="4" height="6" fill={s.sun} opacity={(k + r) % 3 ? 0.18 : 0.75} />
251 ))}
252 </g>
253 ))}
254 <rect y="250" width="220" height="30" fill={s.hills[2]} />
255 </>
256 )}
257 {s.kind === "bloom" && (
258 <>
259 <rect y="190" width="220" height="90" fill={s.hills[0]} />
260 <path d="M96 190 h28 l-4 -64 h-20z" fill={s.hills[2]} />
261 {Array.from({ length: 6 }, (_, k) => {
262 const a = (k / 6) * Math.PI * 2;
263 return <ellipse key={k} cx={110 + Math.cos(a) * 16} cy={104 + Math.sin(a) * 16} rx="16" ry="9" transform={`rotate(${(a * 180) / Math.PI} ${110 + Math.cos(a) * 16} ${104 + Math.sin(a) * 16})`} fill={s.sun} opacity=".92" />;
264 })}
265 <circle cx="110" cy="104" r="9" fill="#F2C14E" />
266 <ellipse cx="132" cy="196" rx="44" ry="6" fill={s.hills[1]} opacity=".5" />
267 </>
268 )}
269 {s.kind === "portrait" && (
270 <>
271 <ellipse cx="110" cy="300" rx="90" ry="90" fill={s.hills[0]} />
272 <ellipse cx="110" cy="130" rx="40" ry="50" fill="#C99A7A" />
273 <path d="M66 128 C64 70 156 60 154 126 C150 100 128 86 110 92 C92 86 72 100 66 128Z" fill={s.hills[2]} />
274 <rect x="98" y="172" width="24" height="34" fill="#B98868" />
275 <ellipse cx="128" cy="118" rx="22" ry="40" fill={s.sun} opacity=".18" />
276 </>
277 )}
278 <rect width="220" height="280" fill={`url(#${g}l)`} />
279 <rect width="220" height="280" filter={`url(#${g}n)`} />
280 <text x="12" y="266" fill="#fff" fillOpacity=".86" style={{ font: '500 10px ui-monospace,"SF Mono",Menlo,monospace', letterSpacing: ".04em" }}>{s.caption}</text>
281 </svg>
282 );
283}
284
285/* ───────────────────────────── demo ───────────────────────────── */
286
287export function Demo() {
288 const cards = SCENES.map((s, i) => <PhotoCard key={i} s={s} i={i} />);
289 return (
290 <div className="fd-it-demo">
291 <style>{demoCss}</style>
292 <ImageTrail items={cards} className="fd-it-demo-area" spacing={84} max={14} life={1200}>
293 <header className="fd-it-demo-bar">
294 <span className="fd-it-demo-name">Noor Halvorsen</span>
295 <nav className="fd-it-demo-nav" aria-label="Primary">
296 <a href="#work">Work</a><a href="#journal">Journal</a><a href="#prints">Prints</a><a href="#contact">Contact</a>
297 </nav>
298 </header>
299 <main className="fd-it-demo-main">
300 <p className="fd-it-demo-eyebrow">Photographer · Oslo &amp; on assignment</p>
301 <h1 className="fd-it-demo-h1">
302 Light, <em>wherever</em><br />it lands.
303 </h1>
304 <p className="fd-it-demo-hint" aria-hidden="true">
305 <span className="fd-it-demo-cursor" />
306 Move your cursor to leaf through the archive
307 </p>
308 </main>
309 <footer className="fd-it-demo-foot">
310 <span>Clients — Kinfolk, Monocle, Aesop, Norrøna</span>
311 <span>Archive 2016 — 2026 · 4,812 frames</span>
312 </footer>
313 </ImageTrail>
314 </div>
315 );
316}
317
318const demoCss = `
319.fd-it-demo,.fd-it-demo *,.fd-it-demo *::before,.fd-it-demo *::after{box-sizing:border-box}
320.fd-it-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:"SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;
321 --d-serif:ui-serif,"New York","Iowan Old Style",Charter,Georgia,serif;
322 margin:0;padding:0;min-height:100vh;background:#EDEAE4;color:#141412;font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}
323.fd-it-demo-area{min-height:100vh}
324.fd-it-demo-area .fd-it-content{display:flex;flex-direction:column;min-height:100vh;pointer-events:none}
325.fd-it-demo-area a{pointer-events:auto}
326.fd-it-demo-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:22px clamp(16px,4vw,48px)}
327.fd-it-demo-name{margin:0;font:600 17px/1 var(--d-disp);letter-spacing:-.01em}
328.fd-it-demo-nav{display:flex;gap:28px;margin:0}
329.fd-it-demo-nav a{margin:0;padding:6px 0;color:rgba(20,20,18,.6);font:500 15px/1 var(--d-font);text-decoration:none}
330.fd-it-demo-nav a:hover{color:#141412}
331.fd-it-demo a:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
332.fd-it-demo-main{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;margin:0;padding:24px 16px;text-align:center}
333.fd-it-demo-eyebrow{margin:0 0 24px;color:rgba(20,20,18,.6);font:500 13px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.06em;text-transform:uppercase}
334.fd-it-demo-h1{margin:0;color:#141412;font:500 clamp(56px,11vw,168px)/.9 var(--d-disp);letter-spacing:-.055em;mix-blend-mode:multiply}
335.fd-it-demo-h1 em{font:italic 400 1em/.9 var(--d-serif);letter-spacing:-.04em}
336.fd-it-demo-hint{display:inline-flex;align-items:center;gap:10px;margin:36px 0 0;color:rgba(20,20,18,.55);font:500 13px/1 var(--d-font)}
337.fd-it-demo-cursor{display:block;width:10px;height:10px;border-radius:50%;background:#141412;animation:fd-it-demo-pulse 1.8s cubic-bezier(.22,1,.36,1) infinite}
338@keyframes fd-it-demo-pulse{0%{box-shadow:0 0 0 0 rgba(20,20,18,.35)}100%{box-shadow:0 0 0 14px rgba(20,20,18,0)}}
339.fd-it-demo-foot{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:0;padding:20px clamp(16px,4vw,48px);color:rgba(20,20,18,.55);font:500 13px/1.3 var(--d-font)}
340@media (max-width:640px){.fd-it-demo-nav{display:none}}
341@media (prefers-reduced-motion:reduce){.fd-it-demo-cursor{animation:none}}
342`;

More Cursors & Interactions. Same file contract.

Custom Cursor

A dot and a spring-lagged ring that morph into labels like View, Drag or Play and snap magnetically around buttons.

Magnetic Dock

A macOS-style dock: icons swell with a gaussian falloff, labels float up, launches bounce and running apps get a dot.

Fisheye Grid

An infinite two-axis tile wall you drag with momentum, with a loupe-like lens that swells tiles around the cursor.