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.
A dot and a spring-lagged ring that morph into labels like View, Drag or Play and snap magnetically around buttons.
Browser support · All evergreen browsers. Fine pointers only — touch devices keep the native behaviour. Reduced motion removes lag and leaning.
npx shadcn@latest add https://freedesignmd.com/r/custom-cursor.json1/**2 * CustomCursor — a cursor system for a scope (a section or the whole page):3 * a precise dot plus a ring that follows on a spring. Over elements with4 * data attributes the ring morphs:5 *6 * data-cursor="View" | "Drag" | "Play" | any short label → ring grows into a labelled disc7 * data-cursor-magnetic → ring snaps around the element, which leans toward the pointer8 * data-cursor="hide" → hide the custom cursor (e.g. over text inputs)9 *10 * Props11 * - children The scope. The native cursor is hidden inside it (pointer: fine only).12 * - color?: string Dot / disc colour. Default "#F5F3EE".13 * - blend?: boolean mix-blend-mode: difference for the ring. Default true.14 * - stiffness?, damping? Ring spring. Defaults 380 / 30 (the dot is exact).15 * - className / style16 * CSS vars: --fd-cc-color, --fd-cc-ring (ring diameter, default 36px), --fd-cc-disc (label disc, default 88px).17 *18 * Behaviour: hidden on touch / coarse pointers and until the first move; the19 * native cursor comes back over form fields. Keyboard users are unaffected20 * (the cursor is aria-hidden and pointer-only). Reduced motion: the ring21 * follows without spring lag and magnetic leaning is off.22 * Performance: one rAF loop that sleeps when the ring is at rest; target23 * rects are read on enter, writes are transform-only.24 * Browser support: all evergreen browsers.25 */26import { useEffect, useRef, useState } from "react";27import type { CSSProperties, ReactNode } from "react";28 29export interface CustomCursorProps {30 children: ReactNode;31 color?: string;32 blend?: boolean;33 stiffness?: number;34 damping?: number;35 className?: string;36 style?: CSSProperties;37}38 39export function CustomCursor({ children, color = "#F5F3EE", blend = true, stiffness = 380, damping = 30, className, style }: CustomCursorProps) {40 const scope = useRef<HTMLDivElement>(null);41 const dot = useRef<HTMLDivElement>(null);42 const ring = useRef<HTMLDivElement>(null);43 const label = useRef<HTMLSpanElement>(null);44 45 useEffect(() => {46 const el = scope.current;47 const d = dot.current;48 const r = ring.current;49 const lb = label.current;50 if (!el || !d || !r || !lb) return;51 const fine = window.matchMedia?.("(pointer: fine)").matches ?? true;52 if (!fine) return;53 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;54 el.setAttribute("data-cc-on", "");55 56 const s = { x: 0, y: 0, vx: 0, vy: 0, w: 36, vw: 0, h: 36, vh: 0 };57 const t = { x: 0, y: 0, w: 36, h: 36 };58 let mx = 0;59 let my = 0;60 let raf = 0;61 let last = 0;62 let shown = false;63 let mag: HTMLElement | null = null;64 let magRect: DOMRect | null = null;65 let ringBase = 36;66 let disc = 88;67 68 const readVars = () => {69 const cs = getComputedStyle(el);70 ringBase = parseFloat(cs.getPropertyValue("--fd-cc-ring")) || 36;71 disc = parseFloat(cs.getPropertyValue("--fd-cc-disc")) || 88;72 };73 readVars();74 75 const setTarget = () => {76 if (mag && magRect) {77 const er = el.getBoundingClientRect();78 const cx = magRect.left + magRect.width / 2 - er.left;79 const cy = magRect.top + magRect.height / 2 - er.top;80 // ring hugs the element, offset a little toward the pointer81 t.x = cx + (mx - cx) * 0.12;82 t.y = cy + (my - cy) * 0.12;83 t.w = magRect.width + 14;84 t.h = magRect.height + 14;85 if (!reduced) {86 const lx = (mx - cx) * 0.22;87 const ly = (my - cy) * 0.22;88 mag.style.transform = `translate3d(${lx.toFixed(2)}px,${ly.toFixed(2)}px,0)`;89 }90 } else {91 t.x = mx;92 t.y = my;93 const big = el.getAttribute("data-cc-state") === "label";94 t.w = t.h = big ? disc : ringBase;95 }96 };97 98 const write = () => {99 d.style.transform = `translate3d(${mx}px,${my}px,0)`;100 r.style.transform = `translate3d(${(s.x - s.w / 2).toFixed(2)}px,${(s.y - s.h / 2).toFixed(2)}px,0)`;101 r.style.width = `${s.w.toFixed(2)}px`;102 r.style.height = `${s.h.toFixed(2)}px`;103 };104 105 const tick = (now: number) => {106 raf = 0;107 const dt = Math.min(0.032, (now - last) / 1000 || 0.016);108 last = now;109 setTarget();110 if (reduced) {111 s.x = t.x;112 s.y = t.y;113 s.w = t.w;114 s.h = t.h;115 } else {116 const h = dt / 2;117 for (let k = 0; k < 2; k++) {118 s.vx += (-stiffness * (s.x - t.x) - damping * s.vx) * h;119 s.vy += (-stiffness * (s.y - t.y) - damping * s.vy) * h;120 s.vw += (-stiffness * 1.1 * (s.w - t.w) - damping * s.vw) * h;121 s.vh += (-stiffness * 1.1 * (s.h - t.h) - damping * s.vh) * h;122 s.x += s.vx * h;123 s.y += s.vy * h;124 s.w += s.vw * h;125 s.h += s.vh * h;126 }127 }128 write();129 const rest = Math.abs(s.x - t.x) + Math.abs(s.y - t.y) + Math.abs(s.w - t.w) + Math.abs(s.h - t.h) < 0.2 && Math.abs(s.vx) + Math.abs(s.vy) < 0.5;130 if (!rest) raf = requestAnimationFrame(tick);131 };132 const kick = () => {133 if (!raf) {134 last = performance.now();135 raf = requestAnimationFrame(tick);136 }137 };138 139 const findTarget = (n: EventTarget | null) => (n instanceof Element ? (n.closest("[data-cursor],[data-cursor-magnetic]") as HTMLElement | null) : null);140 141 const onMove = (e: PointerEvent) => {142 if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;143 const er = el.getBoundingClientRect();144 mx = e.clientX - er.left;145 my = e.clientY - er.top;146 if (!shown) {147 shown = true;148 s.x = t.x = mx;149 s.y = t.y = my;150 el.setAttribute("data-cc-visible", "");151 }152 d.style.transform = `translate3d(${mx}px,${my}px,0)`;153 kick();154 };155 156 const onOver = (e: PointerEvent) => {157 const tg = findTarget(e.target);158 const native = e.target instanceof Element && e.target.closest("input,textarea,select,[contenteditable='true']");159 const lab = tg?.getAttribute("data-cursor") ?? "";160 if (native || lab === "hide") el.setAttribute("data-cc-state", "hide");161 else if (tg?.hasAttribute("data-cursor-magnetic")) {162 if (mag !== tg) {163 releaseMag();164 mag = tg;165 magRect = tg.getBoundingClientRect();166 // radius of the ring follows the element's own radius167 r.style.borderRadius = getComputedStyle(tg).borderRadius || "9999px";168 tg.style.transition = "transform 120ms linear";169 }170 el.setAttribute("data-cc-state", "magnetic");171 } else if (lab) {172 releaseMag();173 lb.textContent = lab;174 el.setAttribute("data-cc-state", "label");175 } else {176 releaseMag();177 el.removeAttribute("data-cc-state");178 }179 kick();180 };181 182 const releaseMag = () => {183 if (!mag) return;184 const m = mag;185 m.style.transition = "transform 420ms cubic-bezier(.34,1.56,.64,1)";186 m.style.transform = "";187 mag = null;188 magRect = null;189 r.style.borderRadius = "";190 };191 192 const onLeave = () => {193 shown = false;194 releaseMag();195 el.removeAttribute("data-cc-visible");196 el.removeAttribute("data-cc-state");197 };198 const onDown = () => el.setAttribute("data-cc-down", "");199 const onUp = () => el.removeAttribute("data-cc-down");200 const onScroll = () => {201 if (mag) magRect = mag.getBoundingClientRect();202 };203 204 el.addEventListener("pointermove", onMove, { passive: true });205 el.addEventListener("pointerover", onOver);206 el.addEventListener("pointerleave", onLeave);207 el.addEventListener("pointerdown", onDown);208 window.addEventListener("pointerup", onUp);209 window.addEventListener("scroll", onScroll, { passive: true, capture: true });210 window.addEventListener("resize", readVars);211 return () => {212 cancelAnimationFrame(raf);213 releaseMag();214 el.removeAttribute("data-cc-on");215 el.removeEventListener("pointermove", onMove);216 el.removeEventListener("pointerover", onOver);217 el.removeEventListener("pointerleave", onLeave);218 el.removeEventListener("pointerdown", onDown);219 window.removeEventListener("pointerup", onUp);220 window.removeEventListener("scroll", onScroll, { capture: true });221 window.removeEventListener("resize", readVars);222 };223 }, [stiffness, damping]);224 225 return (226 <div ref={scope} className={`fd-cc${className ? " " + className : ""}`} data-blend={blend ? "" : undefined} style={{ ["--fd-cc-color" as string]: color, ...style }}>227 <style>{css}</style>228 {children}229 <div className="fd-cc-layer" aria-hidden="true">230 <div ref={ring} className="fd-cc-ring">231 <span ref={label} className="fd-cc-label" />232 </div>233 <div ref={dot} className="fd-cc-dot" />234 </div>235 </div>236 );237}238 239const css = `240.fd-cc,.fd-cc-layer,.fd-cc-layer *{box-sizing:border-box}241.fd-cc{--fd-cc-ring:36px;--fd-cc-disc:88px;position:relative;margin:0;padding:0}242.fd-cc[data-cc-on],.fd-cc[data-cc-on] *{cursor:none}243.fd-cc[data-cc-on] :is(input,textarea,select,[contenteditable="true"]){cursor:auto}244.fd-cc-layer{position:absolute;inset:0;margin:0;padding:0;pointer-events:none;z-index:2147483000;overflow:hidden;opacity:0;transition:opacity 240ms cubic-bezier(.22,1,.36,1)}245.fd-cc[data-cc-visible] .fd-cc-layer{opacity:1}246.fd-cc[data-cc-state="hide"] .fd-cc-layer{opacity:0}247.fd-cc-dot{position:absolute;left:-3px;top:-3px;width:6px;height:6px;margin:0;padding:0;border-radius:50%;background:var(--fd-cc-color);will-change:transform;248 transition:opacity 120ms,scale 240ms cubic-bezier(.34,1.56,.64,1)}249.fd-cc-ring{position:absolute;left:0;top:0;display:grid;place-items:center;width:var(--fd-cc-ring);height:var(--fd-cc-ring);margin:0;padding:0;border-radius:9999px;250 border:1.5px solid var(--fd-cc-color);background:transparent;will-change:transform,width,height;251 transition:background-color 240ms cubic-bezier(.22,1,.36,1),border-color 240ms,border-radius 240ms cubic-bezier(.65,0,.35,1),scale 120ms cubic-bezier(.22,1,.36,1)}252.fd-cc[data-blend] .fd-cc-ring{mix-blend-mode:difference}253.fd-cc[data-blend] .fd-cc-dot{mix-blend-mode:difference}254.fd-cc-label{margin:0;padding:0;opacity:0;scale:.6;color:#0B0B0F;font:600 13px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter",sans-serif;letter-spacing:-.005em;white-space:nowrap;255 transition:opacity 120ms,scale 240ms cubic-bezier(.34,1.56,.64,1)}256.fd-cc[data-cc-state="label"] .fd-cc-ring{background:var(--fd-cc-color);border-color:var(--fd-cc-color);mix-blend-mode:normal}257.fd-cc[data-cc-state="label"] .fd-cc-label{opacity:1;scale:1;transition-delay:60ms}258.fd-cc[data-cc-state="label"] .fd-cc-dot,.fd-cc[data-cc-state="magnetic"] .fd-cc-dot{opacity:0;scale:.3}259.fd-cc[data-cc-state="magnetic"] .fd-cc-ring{background:color-mix(in srgb,var(--fd-cc-color) 12%,transparent)}260.fd-cc[data-cc-down] .fd-cc-ring{scale:.88}261@media (pointer:coarse){.fd-cc-layer{display:none}}262`;263 264/* ───────────────────────────── demo ───────────────────────────── */265 266const WORK = [267 { t: "Halden Bank", k: "Identity · 2026", c1: "#2A3BFF", c2: "#9AA6FF", cursor: "View" },268 { t: "Oda Hotels", k: "Booking · 2025", c1: "#E2582B", c2: "#FFC9A8", cursor: "View" },269 { t: "Veloq E-bikes", k: "Launch film · 2026", c1: "#0F3D2E", c2: "#7FE0B6", cursor: "Play" },270];271 272export function Demo() {273 const [sent, setSent] = useState(false);274 return (275 <CustomCursor className="fd-cc-demo">276 <style>{demoCss}</style>277 <header className="fd-cc-demo-bar">278 <span className="fd-cc-demo-brand">Studio Tannery</span>279 <nav className="fd-cc-demo-nav" aria-label="Primary">280 {["Work", "Approach", "Journal"].map((n) => (281 <a key={n} href={`#${n.toLowerCase()}`} data-cursor-magnetic>{n}</a>282 ))}283 </nav>284 <a className="fd-cc-demo-pill" href="#contact" data-cursor-magnetic>Start a project</a>285 </header>286 287 <main className="fd-cc-demo-main">288 <h1 className="fd-cc-demo-h1">289 Brand worlds for companies <span>with something to say.</span>290 </h1>291 292 <div className="fd-cc-demo-strip" data-cursor="Drag" role="region" aria-label="Selected work — scroll horizontally" tabIndex={0}>293 {WORK.map((w) => (294 <a key={w.t} href={`#${w.t.toLowerCase().replace(/\s/g, "-")}`} className="fd-cc-demo-card" data-cursor={w.cursor}295 style={{ background: `radial-gradient(120% 90% at 20% 10%, ${w.c2} 0%, ${w.c1} 62%)` }}>296 <span className="fd-cc-demo-shape" aria-hidden="true" />297 <span className="fd-cc-demo-meta">298 <strong>{w.t}</strong>299 <span>{w.k}</span>300 </span>301 </a>302 ))}303 </div>304 305 <form className="fd-cc-demo-form" onSubmit={(e) => { e.preventDefault(); setSent(true); }}>306 <label className="fd-cc-demo-lab" htmlFor="fd-cc-demo-email">Get our quarterly letter</label>307 <div className="fd-cc-demo-row">308 <input id="fd-cc-demo-email" type="email" required placeholder="you@company.com" className="fd-cc-demo-input" />309 <button type="submit" className="fd-cc-demo-btn" data-cursor-magnetic>{sent ? "Subscribed" : "Subscribe"}</button>310 </div>311 </form>312 </main>313 </CustomCursor>314 );315}316 317const demoCss = `318.fd-cc-demo,.fd-cc-demo *,.fd-cc-demo *::before,.fd-cc-demo *::after{box-sizing:border-box}319.fd-cc-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;320 min-height:100vh;margin:0;padding:0;background:#0E0E10;color:#F5F3EE;font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased;overflow:hidden}321.fd-cc-demo-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:20px clamp(16px,4vw,48px)}322.fd-cc-demo-brand{margin:0;font:600 17px/1 var(--d-disp);letter-spacing:-.01em}323.fd-cc-demo-nav{display:flex;gap:8px;margin:0}324.fd-cc-demo-nav a{display:inline-flex;align-items:center;height:36px;margin:0;padding:0 14px;border-radius:9999px;color:rgba(245,243,238,.7);font:500 15px/1 var(--d-font);text-decoration:none}325.fd-cc-demo-nav a:hover{color:#F5F3EE}326.fd-cc-demo-pill{display:inline-flex;align-items:center;height:40px;margin:0;padding:0 18px;border-radius:9999px;background:#F5F3EE;color:#0E0E10;font:600 14px/1 var(--d-font);text-decoration:none}327.fd-cc-demo a:focus-visible,.fd-cc-demo button:focus-visible,.fd-cc-demo-strip:focus-visible{outline:2px solid rgba(10,132,255,.8);outline-offset:2px}328.fd-cc-demo-main{max-width:1320px;margin:0 auto;padding:clamp(16px,4vh,48px) clamp(16px,4vw,48px) 48px}329.fd-cc-demo-h1{max-width:14ch;margin:0;color:#F5F3EE;font:600 clamp(40px,5.6vw,84px)/.95 var(--d-disp);letter-spacing:-.045em;text-wrap:balance}330.fd-cc-demo-h1 span{color:rgba(245,243,238,.42)}331.fd-cc-demo-strip{display:flex;gap:16px;margin:clamp(24px,5vh,48px) 0 0;padding:0 0 8px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x mandatory}332.fd-cc-demo-strip::-webkit-scrollbar{display:none}333.fd-cc-demo-card{position:relative;flex:0 0 clamp(260px,30vw,400px);aspect-ratio:3/2;margin:0;padding:0;border-radius:24px;overflow:hidden;scroll-snap-align:start;text-decoration:none;color:#fff}334.fd-cc-demo-shape{position:absolute;right:-12%;bottom:-24%;width:70%;aspect-ratio:1;border-radius:50%;background:rgba(255,255,255,.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,.2)}335.fd-cc-demo-meta{position:absolute;left:20px;bottom:18px;display:flex;flex-direction:column;gap:4px;margin:0}336.fd-cc-demo-meta strong{font:600 20px/1.1 var(--d-disp);letter-spacing:-.01em}337.fd-cc-demo-meta span{font:500 13px/1 var(--d-font);opacity:.8}338.fd-cc-demo-form{margin:clamp(24px,4vh,40px) 0 0;max-width:520px}339.fd-cc-demo-lab{display:block;margin:0 0 10px;color:rgba(245,243,238,.6);font:500 13px/1 var(--d-font)}340.fd-cc-demo-row{display:flex;gap:8px;margin:0}341.fd-cc-demo-input{flex:1;min-width:0;height:48px;margin:0;padding:0 18px;border:0;border-radius:9999px;background:rgba(245,243,238,.08);box-shadow:inset 0 0 0 1px rgba(245,243,238,.12);342 color:#F5F3EE;font:400 15px/1 var(--d-font);outline:0;appearance:none;-webkit-appearance:none}343.fd-cc-demo-input:focus{box-shadow:inset 0 0 0 1.5px rgba(10,132,255,.9)}344.fd-cc-demo-input::placeholder{color:rgba(245,243,238,.4)}345.fd-cc-demo-btn{appearance:none;-webkit-appearance:none;height:48px;margin:0;padding:0 22px;border:0;border-radius:9999px;background:#D7FF5C;color:#0E0E10;font:600 15px/1 var(--d-font);cursor:pointer}346@media (max-width:640px){.fd-cc-demo-nav{display:none}}347`;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 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.