Custom Cursor

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

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

Browser support · All evergreen browsers. Fine pointers only — touch devices keep the native behaviour. Reduced motion removes lag and leaning.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/custom-cursor.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/interactions/custom-cursor.tsx
1/**
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 with
4 * data attributes the ring morphs:
5 *
6 * data-cursor="View" | "Drag" | "Play" | any short label → ring grows into a labelled disc
7 * data-cursor-magnetic → ring snaps around the element, which leans toward the pointer
8 * data-cursor="hide" → hide the custom cursor (e.g. over text inputs)
9 *
10 * Props
11 * - 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 / style
16 * 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; the
19 * native cursor comes back over form fields. Keyboard users are unaffected
20 * (the cursor is aria-hidden and pointer-only). Reduced motion: the ring
21 * follows without spring lag and magnetic leaning is off.
22 * Performance: one rAF loop that sleeps when the ring is at rest; target
23 * 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 pointer
81 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 radius
167 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`;

More Cursors & Interactions. Same file contract.

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.

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.