Custom Cursor
A dot and a spring-lagged ring that morph into labels like View, Drag or Play and snap magnetically around buttons.
Cards spawn along the cursor path at fixed intervals, pop in with a tilt and fall away — a portfolio you leaf through by moving.
Browser support · All evergreen browsers (Web Animations API). Touch spawns along drags; off with reduced motion.
npx shadcn@latest add https://freedesignmd.com/r/image-trail.json1/**2 * ImageTrail — as the pointer travels across an area, image cards spawn at3 * 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 capped5 * so long gestures never pile up DOM.6 *7 * Props8 * - 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 / style17 * 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). Pointer21 * 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 content66 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 early99 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 segment127 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-deps157 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 & 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 archive307 </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`;A dot and a spring-lagged ring that morph into labels like View, Drag or Play and snap magnetically around buttons.
A macOS-style dock: icons swell with a gaussian falloff, labels float up, launches bounce and running apps get a dot.
An infinite two-axis tile wall you drag with momentum, with a loupe-like lens that swells tiles around the cursor.