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.
An infinite two-axis tile wall you drag with momentum, with a loupe-like lens that swells tiles around the cursor.
Browser support · All evergreen browsers. Pointer, wheel and arrow keys pan; reduced motion removes momentum and the lens.
npx shadcn@latest add https://freedesignmd.com/r/fisheye-grid.json1/**2 * FisheyeGrid — an infinite, two-axis tile wall you drag around with3 * momentum. A lens bulges the tiles around the pointer (or the centre when4 * the pointer is away): nearby tiles swell and push their neighbours out,5 * far tiles shrink back, like looking through a loupe.6 *7 * Props8 * - count: number How many distinct tiles exist (wall repeats them).9 * - renderTile: (i) => ReactNode Render tile content for index i (0..count-1).10 * - tile?: number Tile size in px. Default 180 (130 under 640px).11 * - gap?: number Gap in px. Default 14.12 * - lens?: number Bulge strength 0–1. Default .55.13 * - lensRadius?: number Lens radius in px. Default 300.14 * - onSelect?: (i) => void Tile click (ignored after a drag).15 * - label?: string Accessible name of the wall.16 * - className / style Size the wall (it fills its box; give it a height).17 * CSS vars: --fd-fg-radius (tile radius, default 16px), --fd-fg-bg.18 *19 * Implementation: a pooled grid of absolutely positioned tiles (only enough20 * to cover the viewport + one ring) wraps around as you pan; every frame21 * computes each tile's world cell, applies the lens displacement and writes22 * one transform. Content only re-renders when a tile wraps to a new cell.23 * Momentum uses exponential friction; pointer, wheel/trackpad and arrow keys pan.24 * Accessibility: the wall is a focusable region (arrow keys pan, Enter picks25 * the centre tile); tiles are aria-hidden duplicates, so pass a list of the26 * real items elsewhere if they matter (the Demo has one).27 * Reduced motion: no momentum glide and no lens.28 * Browser support: all evergreen browsers.29 */30import { useEffect, useRef, useState } from "react";31import type { CSSProperties, KeyboardEvent, ReactNode } from "react";32 33export interface FisheyeGridProps {34 count: number;35 renderTile: (i: number) => ReactNode;36 tile?: number;37 gap?: number;38 lens?: number;39 lensRadius?: number;40 onSelect?: (i: number) => void;41 label?: string;42 className?: string;43 style?: CSSProperties;44}45 46const cellIndex = (cx: number, cy: number, count: number) => {47 // stable pseudo-random index per world cell, avoids visible repetition rows48 let h = (cx * 374761393 + cy * 668265263) | 0;49 h = Math.imul(h ^ (h >>> 13), 1274126177);50 h ^= h >>> 16;51 return ((h % count) + count) % count;52};53 54export function FisheyeGrid({ count, renderTile, tile = 180, gap = 14, lens = 0.55, lensRadius = 300, onSelect, label = "Tile wall", className, style }: FisheyeGridProps) {55 const root = useRef<HTMLDivElement>(null);56 const [dims, setDims] = useState({ cols: 0, rows: 0, size: tile });57 // which content index each pool slot shows (state so React renders content)58 const [slots, setSlots] = useState<number[]>([]);59 const api = useRef<{ panBy: (x: number, y: number) => void; centerIndex: () => number }>({ panBy: () => {}, centerIndex: () => 0 });60 61 useEffect(() => {62 const el = root.current;63 if (!el) return;64 const measure = () => {65 const size = el.clientWidth < 640 ? Math.round(tile * 0.72) : tile;66 const pitch = size + gap;67 const cols = Math.ceil(el.clientWidth / pitch) + 3;68 const rows = Math.ceil(el.clientHeight / pitch) + 3;69 setDims((d) => (d.cols === cols && d.rows === rows && d.size === size ? d : { cols, rows, size }));70 };71 measure();72 const ro = new ResizeObserver(measure);73 ro.observe(el);74 return () => ro.disconnect();75 }, [tile, gap]);76 77 useEffect(() => {78 const el = root.current;79 const { cols, rows, size } = dims;80 if (!el || !cols) return;81 const tiles = Array.from(el.querySelectorAll<HTMLElement>(".fd-fg-tile"));82 if (tiles.length !== cols * rows) return;83 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;84 const pitch = size + gap;85 let W = el.clientWidth;86 let H = el.clientHeight;87 // camera88 let ox = 0;89 let oy = 0;90 let vx = 0;91 let vy = 0;92 // lens centre (smoothed) and target93 let lx = W / 2;94 let ly = H / 2;95 let tlx = lx;96 let tly = ly;97 let lensAmt = reduced ? 0 : lens;98 let dragging = false;99 let moved = 0;100 let lastPX = 0;101 let lastPY = 0;102 let lastPT = 0;103 let raf = 0;104 let last = 0;105 let visible = true;106 const assigned = new Int32Array(cols * rows).fill(-1);107 let pendingSlots: number[] | null = null;108 109 const frame = (t: number) => {110 raf = 0;111 const dt = Math.min(0.05, (t - last) / 1000 || 0.016);112 last = t;113 if (!dragging && !reduced) {114 const fr = Math.exp(-dt * 4.2);115 ox += vx * dt;116 oy += vy * dt;117 vx *= fr;118 vy *= fr;119 if (Math.abs(vx) < 4) vx = 0;120 if (Math.abs(vy) < 4) vy = 0;121 }122 const ls = 1 - Math.exp(-dt * 10);123 lx += (tlx - lx) * ls;124 ly += (tly - ly) * ls;125 126 const R = el.clientWidth < 640 ? lensRadius * 0.7 : lensRadius;127 let changed = false;128 const next = pendingSlots ?? Array.from(assigned);129 for (let j = 0; j < rows; j++) {130 for (let i = 0; i < cols; i++) {131 const k = j * cols + i;132 // wrap pool slot into the visible window133 const wx = ((((i * pitch + ox) % (cols * pitch)) + cols * pitch) % (cols * pitch)) - pitch * 1.5;134 const wy = ((((j * pitch + oy) % (rows * pitch)) + rows * pitch) % (rows * pitch)) - pitch * 1.5;135 // world cell of this slot136 const cx = Math.round((wx - ox) / pitch);137 const cy = Math.round((wy - oy) / pitch);138 const idx = cellIndex(cx, cy, count);139 if (assigned[k] !== idx) {140 assigned[k] = idx;141 next[k] = idx;142 changed = true;143 }144 // lens: radial displacement + scale around (lx, ly)145 const tx = wx + size / 2;146 const ty = wy + size / 2;147 const dx = tx - lx;148 const dy = ty - ly;149 const d = Math.hypot(dx, dy);150 const g = Math.exp(-(d * d) / (2 * R * R));151 const scale = 1 + lensAmt * 0.9 * g - lensAmt * 0.18 * (1 - g);152 const push = 1 + lensAmt * 0.55 * g * (d / (R + 1)) * 1.6;153 const px = lx + dx * push - size / 2;154 const py = ly + dy * push - size / 2;155 tiles[k].style.transform = `translate3d(${px.toFixed(1)}px,${py.toFixed(1)}px,0) scale(${scale.toFixed(4)})`;156 tiles[k].style.zIndex = String(Math.round(g * 100));157 }158 }159 if (changed) {160 pendingSlots = next;161 setSlots(next.slice());162 pendingSlots = null;163 }164 const busy = dragging || Math.abs(vx) + Math.abs(vy) > 0 || Math.abs(tlx - lx) + Math.abs(tly - ly) > 0.3;165 if (busy && visible && !document.hidden) raf = requestAnimationFrame(frame);166 };167 const kick = () => {168 if (!raf && visible) {169 last = performance.now();170 raf = requestAnimationFrame(frame);171 }172 };173 174 const onDown = (e: PointerEvent) => {175 if (e.button !== 0) return;176 dragging = true;177 moved = 0;178 vx = vy = 0;179 lastPX = e.clientX;180 lastPY = e.clientY;181 lastPT = performance.now();182 el.setPointerCapture(e.pointerId);183 el.setAttribute("data-dragging", "");184 kick();185 };186 const onMove = (e: PointerEvent) => {187 const r = el.getBoundingClientRect();188 if (e.pointerType !== "touch") {189 tlx = e.clientX - r.left;190 tly = e.clientY - r.top;191 }192 if (dragging) {193 const now = performance.now();194 const dx = e.clientX - lastPX;195 const dy = e.clientY - lastPY;196 const dtt = Math.max(1, now - lastPT) / 1000;197 ox += dx;198 oy += dy;199 moved += Math.abs(dx) + Math.abs(dy);200 // smoothed release velocity201 vx = vx * 0.6 + (dx / dtt) * 0.4;202 vy = vy * 0.6 + (dy / dtt) * 0.4;203 lastPX = e.clientX;204 lastPY = e.clientY;205 lastPT = now;206 }207 kick();208 };209 const onUp = (e: PointerEvent) => {210 if (!dragging) return;211 dragging = false;212 el.removeAttribute("data-dragging");213 if (performance.now() - lastPT > 80) vx = vy = 0;214 if (moved < 6) {215 vx = vy = 0;216 const t = (e.target as Element | null)?.closest?.(".fd-fg-tile") as HTMLElement | null;217 if (t) onSelectRef.current?.(Number(t.dataset.idx));218 }219 kick();220 };221 const onLeave = () => {222 tlx = W / 2;223 tly = H / 2;224 kick();225 };226 const onWheel = (e: WheelEvent) => {227 e.preventDefault();228 ox -= e.deltaX;229 oy -= e.deltaY;230 kick();231 };232 const onResize = () => {233 W = el.clientWidth;234 H = el.clientHeight;235 tlx = W / 2;236 tly = H / 2;237 kick();238 };239 const io = new IntersectionObserver((es) => {240 visible = es.some((x) => x.isIntersecting);241 if (visible) kick();242 });243 io.observe(el);244 api.current = {245 panBy: (x: number, y: number) => {246 if (reduced) {247 ox += x;248 oy += y;249 } else {250 vx += x * 5;251 vy += y * 5;252 }253 kick();254 },255 centerIndex: () => {256 let best = 0;257 let bd = Infinity;258 tiles.forEach((t) => {259 const r = t.getBoundingClientRect();260 const er = el.getBoundingClientRect();261 const d = Math.hypot(r.left + r.width / 2 - er.left - W / 2, r.top + r.height / 2 - er.top - H / 2);262 if (d < bd) {263 bd = d;264 best = Number(t.dataset.idx);265 }266 });267 return best;268 },269 };270 lensAmt = reduced ? 0 : lens;271 272 el.addEventListener("pointerdown", onDown);273 el.addEventListener("pointermove", onMove);274 el.addEventListener("pointerup", onUp);275 el.addEventListener("pointercancel", onUp);276 el.addEventListener("pointerleave", onLeave);277 el.addEventListener("wheel", onWheel, { passive: false });278 window.addEventListener("resize", onResize);279 // centre the wall on a tile, then draw280 ox = W / 2 - size / 2 - pitch * 1.5 + pitch * 0.5;281 oy = H / 2 - size / 2 - pitch * 1.5 + pitch * 0.5;282 kick();283 return () => {284 cancelAnimationFrame(raf);285 io.disconnect();286 el.removeEventListener("pointerdown", onDown);287 el.removeEventListener("pointermove", onMove);288 el.removeEventListener("pointerup", onUp);289 el.removeEventListener("pointercancel", onUp);290 el.removeEventListener("pointerleave", onLeave);291 el.removeEventListener("wheel", onWheel);292 window.removeEventListener("resize", onResize);293 };294 }, [dims, gap, lens, lensRadius, count]);295 296 const onSelectRef = useRef(onSelect);297 onSelectRef.current = onSelect;298 299 const onKey = (e: KeyboardEvent<HTMLDivElement>) => {300 const step = dims.size + gap;301 const map: Record<string, [number, number]> = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };302 if (map[e.key]) {303 e.preventDefault();304 api.current.panBy(...map[e.key]);305 } else if (e.key === "Enter" || e.key === " ") {306 e.preventDefault();307 onSelect?.(api.current.centerIndex());308 }309 };310 311 const n = dims.cols * dims.rows;312 return (313 <div314 ref={root}315 className={`fd-fg${className ? " " + className : ""}`}316 style={{ ...style, ["--fd-fg-size" as string]: `${dims.size}px` }}317 role="region"318 aria-roledescription="pannable wall"319 aria-label={`${label}. Drag or use arrow keys to pan, Enter to pick the centre tile.`}320 tabIndex={0}321 onKeyDown={onKey}322 >323 <style>{css}</style>324 {Array.from({ length: n }, (_, k) => {325 const idx = slots[k] ?? k % count;326 return (327 <div key={k} className="fd-fg-tile" data-idx={idx} aria-hidden="true">328 {renderTile(idx)}329 </div>330 );331 })}332 </div>333 );334}335 336const css = `337.fd-fg,.fd-fg *,.fd-fg *::before,.fd-fg *::after{box-sizing:border-box}338.fd-fg{--fd-fg-radius:16px;--fd-fg-bg:#0D0D10;position:relative;display:block;margin:0;padding:0;overflow:hidden;background:var(--fd-fg-bg);339 cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;outline:0}340.fd-fg[data-dragging]{cursor:grabbing}341.fd-fg:focus-visible{box-shadow:inset 0 0 0 2px rgba(10,132,255,.8)}342.fd-fg-tile{position:absolute;left:0;top:0;width:var(--fd-fg-size);height:var(--fd-fg-size);margin:0;padding:0;border-radius:var(--fd-fg-radius);overflow:hidden;343 will-change:transform;transform-origin:50% 50%;contain:layout paint;box-shadow:0 10px 30px -14px rgba(0,0,0,.6)}344`;345 346/* ───────────────────────── procedural art cards ───────────────────────── */347 348type Art = { t: string; by: string; bg: string; fg: string; ink: string; kind: number };349 350const ART: Art[] = [351 { t: "Tidal Grid", by: "Mara Olsen", bg: "#F2E9DA", fg: "#E4572E", ink: "#1C1B19", kind: 0 },352 { t: "Night Bus", by: "Kenji Ito", bg: "#14213D", fg: "#FCA311", ink: "#E5E5E5", kind: 1 },353 { t: "Soft Brutal", by: "Ada Novak", bg: "#D9D3F5", fg: "#3A2E8C", ink: "#181530", kind: 2 },354 { t: "Kiln", by: "Studio Ferro", bg: "#3B1F1A", fg: "#E9A178", ink: "#F7E7DA", kind: 3 },355 { t: "Signal", by: "Rui Costa", bg: "#E8FF59", fg: "#111111", ink: "#111111", kind: 4 },356 { t: "Moss Type", by: "Lena Berg", bg: "#1F3B2D", fg: "#B7E4C7", ink: "#EAF7EF", kind: 5 },357 { t: "Halftone Sun", by: "Ola Dahl", bg: "#FFE3D3", fg: "#FF5A36", ink: "#2A1610", kind: 6 },358 { t: "Index 07", by: "Nils Arp", bg: "#F4F4F0", fg: "#0047FF", ink: "#0A0A0A", kind: 7 },359 { t: "Glass Fern", by: "Iris Moreau", bg: "#0E1A24", fg: "#5EEAD4", ink: "#E6FFFB", kind: 2 },360 { t: "Pressure", by: "Tomás Vidal", bg: "#FF4F79", fg: "#FFD6E0", ink: "#2B0A14", kind: 0 },361 { t: "Paper Moon", by: "Yuna Seo", bg: "#202022", fg: "#F2F0EA", ink: "#F2F0EA", kind: 6 },362 { t: "Orbit", by: "Kofi Mensah", bg: "#FFB703", fg: "#023047", ink: "#023047", kind: 1 },363 { t: "Quiet Stack", by: "Elif Kaya", bg: "#E5E1D8", fg: "#8A7F6B", ink: "#2A2620", kind: 3 },364 { t: "Ultraviolet", by: "Max Weber", bg: "#2A0E61", fg: "#C77DFF", ink: "#F3E8FF", kind: 7 },365 { t: "Dune Specimen", by: "Sana Malik", bg: "#E9C46A", fg: "#264653", ink: "#264653", kind: 5 },366 { t: "Coldwave", by: "Jonas Lind", bg: "#CFE8FF", fg: "#0B3D91", ink: "#0B1E3F", kind: 4 },367];368 369function ArtCard({ a }: { a: Art }) {370 const shape: CSSProperties = (() => {371 switch (a.kind) {372 case 0: return { background: `repeating-linear-gradient(90deg, ${a.fg} 0 10%, transparent 10% 20%), ${a.bg}`, maskImage: "radial-gradient(circle at 50% 46%, #000 40%, transparent 41%)" };373 case 1: return { background: `radial-gradient(circle at 50% 46%, ${a.fg} 0 22%, transparent 22.5%), radial-gradient(circle at 50% 46%, transparent 0 32%, ${a.fg} 32.5% 33.5%, transparent 34%)` };374 case 2: return { background: `conic-gradient(from 210deg at 50% 46%, ${a.fg}, ${a.bg}, ${a.fg})`, borderRadius: "48% 52% 38% 62% / 52% 38% 62% 48%", inset: "18% 20% 30%" };375 case 3: return { background: `linear-gradient(${a.fg}, ${a.fg}) 30% 70% / 34% 36% no-repeat, linear-gradient(${a.ink}, ${a.ink}) 62% 44% / 30% 44% no-repeat` };376 case 4: return { background: `repeating-linear-gradient(-45deg, ${a.fg} 0 3px, transparent 3px 12px)`, inset: "16% 16% 30%", borderRadius: "9999px" };377 case 5: return { background: `radial-gradient(120% 80% at 50% 120%, ${a.fg} 0 50%, transparent 51%), radial-gradient(90% 60% at 30% 110%, ${a.ink} 0 50%, transparent 51%)` };378 case 6: return { background: `radial-gradient(${a.fg} 28%, transparent 30%) 0 0 / 9px 9px`, maskImage: "radial-gradient(circle at 50% 44%, #000 34%, transparent 36%)" };379 default: return { background: `linear-gradient(${a.fg}, ${a.fg}) 16% 20% / 68% 6px no-repeat, linear-gradient(${a.fg}, ${a.fg}) 16% 32% / 44% 6px no-repeat, linear-gradient(${a.fg}, ${a.fg}) 16% 44% / 56% 6px no-repeat` };380 }381 })();382 return (383 <div className="fd-fg-art" style={{ background: a.bg, color: a.ink }}>384 <span className="fd-fg-art-shape" style={shape} />385 <span className="fd-fg-art-cap">386 <strong>{a.t}</strong>387 <span>{a.by}</span>388 </span>389 </div>390 );391}392 393/* ───────────────────────────── demo ───────────────────────────── */394 395export function Demo() {396 const [picked, setPicked] = useState<number | null>(null);397 const [saved, setSaved] = useState(0);398 return (399 <div className="fd-fg-demo">400 <style>{demoCss}</style>401 <FisheyeGrid402 count={ART.length}403 renderTile={(i) => <ArtCard a={ART[i]} />}404 label="Inspiration board"405 className="fd-fg-demo-wall"406 onSelect={(i) => {407 setPicked(i);408 setSaved((s) => s + 1);409 }}410 />411 <header className="fd-fg-demo-bar">412 <span className="fd-fg-demo-brand">Moodwall</span>413 <span className="fd-fg-demo-board">Board · <strong>Rebrand 2027 references</strong></span>414 <span className="fd-fg-demo-count" aria-live="polite">{saved} saved</span>415 </header>416 <div className="fd-fg-demo-hint" aria-hidden="true">Drag to explore · click to save</div>417 {picked !== null && (418 <div className="fd-fg-demo-toast" role="status" key={saved}>419 <span className="fd-fg-demo-sw" style={{ background: ART[picked].fg }} />420 Saved “{ART[picked].t}” by {ART[picked].by}421 </div>422 )}423 <ul className="fd-fg-demo-sr" aria-label="Pieces on this board">424 {ART.map((a) => <li key={a.t}>{a.t} by {a.by}</li>)}425 </ul>426 </div>427 );428}429 430const demoCss = `431.fd-fg-demo,.fd-fg-demo *,.fd-fg-demo *::before,.fd-fg-demo *::after{box-sizing:border-box}432.fd-fg-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;433 position:relative;height:100vh;min-height:560px;margin:0;padding:0;overflow:hidden;background:#0D0D10;color:#F5F5F7;font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}434.fd-fg-demo-wall{position:absolute;inset:0}435.fd-fg-art{position:absolute;inset:0;margin:0;padding:0}436.fd-fg-art-shape{position:absolute;inset:10% 10% 26%;display:block;margin:0;padding:0}437.fd-fg-art-cap{position:absolute;left:10%;right:10%;bottom:8%;display:flex;flex-direction:column;gap:2px;margin:0;padding:0}438.fd-fg-art-cap strong{font:600 13px/1.15 var(--d-disp);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}439.fd-fg-art-cap span{font:500 10px/1.2 ui-monospace,"SF Mono",Menlo,monospace;opacity:.7;white-space:nowrap}440.fd-fg-demo-bar{position:absolute;left:16px;right:16px;top:16px;display:flex;align-items:center;gap:16px;margin:0 auto;max-width:720px;height:52px;padding:0 10px 0 20px;border-radius:9999px;441 background:rgba(20,20,24,.62);-webkit-backdrop-filter:blur(24px) saturate(1.6);backdrop-filter:blur(24px) saturate(1.6);box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 20px 40px -20px rgba(0,0,0,.6);pointer-events:none}442.fd-fg-demo-brand{margin:0;font:700 17px/1 var(--d-disp);letter-spacing:-.02em}443.fd-fg-demo-board{margin:0;color:rgba(245,245,247,.6);font:500 13px/1 var(--d-font);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}444.fd-fg-demo-board strong{color:#F5F5F7;font-weight:600}445.fd-fg-demo-count{margin:0 0 0 auto;padding:9px 14px;border-radius:9999px;background:#F5F5F7;color:#0D0D10;font:600 13px/1 var(--d-font);white-space:nowrap}446.fd-fg-demo-hint{position:absolute;left:50%;bottom:20px;margin:0;padding:10px 16px;border-radius:9999px;transform:translateX(-50%);background:rgba(20,20,24,.62);447 -webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);color:rgba(245,245,247,.8);font:500 13px/1 var(--d-font);pointer-events:none;white-space:nowrap}448.fd-fg-demo-toast{position:absolute;left:50%;bottom:68px;display:flex;align-items:center;gap:10px;margin:0;padding:10px 16px 10px 10px;border-radius:9999px;transform:translateX(-50%);449 background:#F5F5F7;color:#0D0D10;font:500 13px/1 var(--d-font);white-space:nowrap;pointer-events:none;animation:fd-fg-demo-toast 2400ms cubic-bezier(.22,1,.36,1) both}450.fd-fg-demo-sw{display:block;width:22px;height:22px;border-radius:50%}451@keyframes fd-fg-demo-toast{0%{opacity:0;transform:translate(-50%,10px)}10%,80%{opacity:1;transform:translate(-50%,0)}100%{opacity:0;transform:translate(-50%,0)}}452.fd-fg-demo-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}453@media (max-width:560px){.fd-fg-demo-board{display:none}}454`;Cards spawn along the cursor path at fixed intervals, pop in with a tilt and fall away — a portfolio you leaf through by moving.
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.