Magnetic Cursor Grid

Portfolio tiles that tilt and lift toward a spring cursor that morphs from dot to ring to a “View” label.

  • Scroll & Hover Effects
  • Creative studio
  • React only · 0 deps
  • 1 file
Full screen

Browser support · Evergreen browsers with a fine pointer. Touch and reduced motion keep the native cursor and flat tiles.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/magnetic-cursor-grid.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/magnetic-cursor-grid.tsx
1/**
2 * MagneticCursorGrid — a grid of tiles that tilt and lift toward a custom
3 * cursor. The cursor is a spring-driven follower that morphs: a dot in empty
4 * space, a ring near tiles, and a filled "View" label over a tile (or any
5 * element with `data-cursor="label text"`).
6 *
7 * Components:
8 * - <CursorStage> provides the follower and tilt behaviour for its subtree.
9 * Props: tilt (deg, default 9), lift (px, default 18), label (default "View").
10 * - Tiles are any elements with `data-magnetic`; their inner `[data-depth]`
11 * children move with parallax proportional to the depth value.
12 * CSS vars: --fd-mcg-cursor, --fd-mcg-cursor-ink.
13 *
14 * Browser support: evergreen. Fine-pointer only — on touch the native cursor
15 * and plain tiles are used.
16 * Accessibility: tiles are links/buttons; keyboard focus lifts a tile the same
17 * way hover does. The follower is aria-hidden. Reduced motion: no tilt, no
18 * follower, native cursor.
19 */
20import { useEffect, useRef } from "react";
21import type { CSSProperties, ReactNode } from "react";
22
23export interface CursorStageProps {
24 children: ReactNode;
25 tilt?: number;
26 lift?: number;
27 label?: string;
28 className?: string;
29 style?: CSSProperties;
30}
31
32type Spring = { x: number; v: number };
33const step = (s: Spring, goal: number, dt: number, k = 380, c = 30) => {
34 for (let i = 0; i < 2; i++) {
35 const h = dt / 2;
36 s.v += (-k * (s.x - goal) - c * s.v) * h;
37 s.x += s.v * h;
38 }
39};
40
41export function CursorStage({ children, tilt = 9, lift = 18, label = "View", className, style }: CursorStageProps) {
42 const root = useRef<HTMLDivElement>(null);
43 const cur = useRef<HTMLDivElement>(null);
44 const txt = useRef<HTMLSpanElement>(null);
45
46 useEffect(() => {
47 const el = root.current;
48 const cu = cur.current;
49 if (!el || !cu) return;
50 const mq = window.matchMedia?.("(pointer: fine)");
51 if (!mq?.matches || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
52 el.dataset.live = "";
53
54 type TileState = { el: HTMLElement; rx: Spring; ry: Spring; z: Spring; depth: HTMLElement[]; tx: number; ty: number; on: boolean };
55 const tiles: TileState[] = Array.from(el.querySelectorAll<HTMLElement>("[data-magnetic]")).map((t) => ({
56 el: t,
57 rx: { x: 0, v: 0 },
58 ry: { x: 0, v: 0 },
59 z: { x: 0, v: 0 },
60 depth: Array.from(t.querySelectorAll<HTMLElement>("[data-depth]")),
61 tx: 0,
62 ty: 0,
63 on: false,
64 }));
65
66 const px = { x: 0, v: 0 };
67 const py = { x: 0, v: 0 };
68 const size = { x: 12, v: 0 };
69 const fillS = { x: 0, v: 0 };
70 let mx = -100;
71 let my = -100;
72 let inside = false;
73 let mode: "dot" | "ring" | "label" = "dot";
74 let text = label;
75 let raf = 0;
76 let last = 0;
77 let rects: DOMRect[] = [];
78 let host: DOMRect = el.getBoundingClientRect();
79 let dirty = true;
80
81 const read = () => {
82 host = el.getBoundingClientRect();
83 rects = tiles.map((t) => t.el.getBoundingClientRect());
84 dirty = false;
85 };
86
87 const tick = (t: number) => {
88 const dt = Math.min(0.032, (t - last) / 1000 || 0.016);
89 last = t;
90 if (dirty) read();
91
92 // decide target mode from pointer position (rects read once per frame)
93 let hit = -1;
94 let near = false;
95 for (let i = 0; i < rects.length; i++) {
96 const r = rects[i];
97 if (mx >= r.left && mx <= r.right && my >= r.top && my <= r.bottom) hit = i;
98 else if (mx >= r.left - 28 && mx <= r.right + 28 && my >= r.top - 28 && my <= r.bottom + 28) near = true;
99 }
100 const custom = hit >= 0 ? tiles[hit].el.getAttribute("data-cursor") : null;
101 mode = hit >= 0 ? "label" : near ? "ring" : "dot";
102 const nextText = custom || label;
103 if (nextText !== text && txt.current) {
104 text = nextText;
105 txt.current.textContent = text;
106 }
107
108 // follower
109 step(px, mx - host.left, dt, 520, 38);
110 step(py, my - host.top, dt, 520, 38);
111 step(size, mode === "label" ? 84 : mode === "ring" ? 44 : 12, dt, 380, 26);
112 step(fillS, mode === "ring" ? 0 : 1, dt, 380, 30);
113 // fixed 84px element, scaled — transform only, no layout
114 const sc = Math.max(0.01, size.x / 84);
115 cu.style.transform = `translate3d(${(px.x - 42).toFixed(2)}px,${(py.x - 42).toFixed(2)}px,0) scale(${sc.toFixed(4)})`;
116 cu.style.setProperty("--ring", `${(1.5 / sc).toFixed(2)}px`);
117 cu.style.setProperty("--fill", fillS.x.toFixed(3));
118 cu.dataset.mode = mode;
119 cu.style.opacity = inside ? "1" : "0";
120
121 let moving = Math.abs(px.v) + Math.abs(py.v) + Math.abs(size.v) > 0.5 || Math.abs(px.x - (mx - host.left)) > 0.3;
122
123 // tiles
124 for (let i = 0; i < tiles.length; i++) {
125 const T = tiles[i];
126 const r = rects[i];
127 const focused = T.el.matches(":focus-visible");
128 const active = i === hit || focused;
129 let nx = 0;
130 let ny = 0;
131 if (i === hit) {
132 nx = ((mx - r.left) / r.width) * 2 - 1;
133 ny = ((my - r.top) / r.height) * 2 - 1;
134 }
135 step(T.rx, active ? -ny * tilt : 0, dt);
136 step(T.ry, active ? nx * tilt : 0, dt);
137 step(T.z, active ? lift : 0, dt);
138 if (active !== T.on) {
139 T.on = active;
140 if (active) T.el.setAttribute("data-hot", "");
141 else T.el.removeAttribute("data-hot");
142 }
143 T.el.style.transform = `perspective(900px) translate3d(0,${(-T.z.x * 0.35).toFixed(2)}px,${T.z.x.toFixed(2)}px) rotateX(${T.rx.x.toFixed(3)}deg) rotateY(${T.ry.x.toFixed(3)}deg)`;
144 T.el.style.setProperty("--fd-mcg-gx", `${((nx + 1) * 50).toFixed(1)}%`);
145 T.el.style.setProperty("--fd-mcg-gy", `${((ny + 1) * 50).toFixed(1)}%`);
146 for (const d of T.depth) {
147 const k = Number(d.dataset.depth) || 0;
148 d.style.transform = `translate3d(${(T.ry.x * k * 1.2).toFixed(2)}px,${(-T.rx.x * k * 1.2).toFixed(2)}px,0)`;
149 }
150 if (Math.abs(T.rx.v) + Math.abs(T.ry.v) + Math.abs(T.z.v) > 0.02 || Math.abs(T.z.x - (active ? lift : 0)) > 0.05) moving = true;
151 }
152 raf = moving ? requestAnimationFrame(tick) : 0;
153 };
154 const kick = () => {
155 if (!raf) {
156 last = performance.now();
157 raf = requestAnimationFrame(tick);
158 }
159 };
160 const onMove = (e: PointerEvent) => {
161 if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
162 if (!inside) {
163 // spawn the follower at the pointer, no fly-in from the corner
164 inside = true;
165 read();
166 px.x = e.clientX - host.left;
167 py.x = e.clientY - host.top;
168 }
169 mx = e.clientX;
170 my = e.clientY;
171 kick();
172 };
173 const onLeave = () => {
174 inside = false;
175 mx = my = -9999;
176 kick();
177 };
178 const mark = () => {
179 dirty = true;
180 kick();
181 };
182 el.addEventListener("pointermove", onMove);
183 el.addEventListener("pointerleave", onLeave);
184 el.addEventListener("focusin", mark);
185 el.addEventListener("focusout", mark);
186 window.addEventListener("scroll", mark, { passive: true, capture: true });
187 window.addEventListener("resize", mark);
188 return () => {
189 el.removeEventListener("pointermove", onMove);
190 el.removeEventListener("pointerleave", onLeave);
191 el.removeEventListener("focusin", mark);
192 el.removeEventListener("focusout", mark);
193 window.removeEventListener("scroll", mark, { capture: true });
194 window.removeEventListener("resize", mark);
195 if (raf) cancelAnimationFrame(raf);
196 delete el.dataset.live;
197 };
198 }, [tilt, lift, label]);
199
200 return (
201 <div ref={root} className={`fd-mcg${className ? " " + className : ""}`} style={style}>
202 <style>{css}</style>
203 {children}
204 <div ref={cur} className="fd-mcg-cursor" data-mode="dot" aria-hidden="true">
205 <span ref={txt} className="fd-mcg-cursor-t">
206 {label}
207 </span>
208 </div>
209 </div>
210 );
211}
212
213const css = `
214.fd-mcg{
215 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-dur-2:240ms;--fd-dur-3:420ms;
216 --fd-mcg-cursor:#FF4D2E;--fd-mcg-cursor-ink:#fff;
217 position:relative;display:block;margin:0;padding:0;
218}
219.fd-mcg,.fd-mcg *{box-sizing:border-box}
220.fd-mcg[data-live],.fd-mcg[data-live] *{cursor:none}
221.fd-mcg-cursor{
222 --fill:1;--ring:1.5px;
223 position:absolute;left:0;top:0;z-index:50;width:84px;height:84px;margin:0;padding:0;pointer-events:none;transform-origin:50% 50%;
224 display:flex;align-items:center;justify-content:center;border-radius:9999px;opacity:0;
225 background:color-mix(in srgb,var(--fd-mcg-cursor) calc(var(--fill) * 100%),transparent);
226 box-shadow:inset 0 0 0 var(--ring) var(--fd-mcg-cursor);
227 transition:opacity var(--fd-dur-2) var(--fd-ease-out);will-change:transform;
228}
229.fd-mcg:not([data-live]) .fd-mcg-cursor{display:none}
230.fd-mcg-cursor-t{
231 color:var(--fd-mcg-cursor-ink);font:600 0.8125rem/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",sans-serif;letter-spacing:-0.005em;
232 opacity:0;transform:scale(.6);transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out);white-space:nowrap;
233}
234.fd-mcg-cursor[data-mode="label"] .fd-mcg-cursor-t{opacity:1;transform:none;transition-delay:60ms}
235[data-magnetic]{transform-style:preserve-3d;will-change:transform}
236`;
237
238/* ------------------------------------------------------------------ Demo */
239
240type Work = { title: string; client: string; disc: string; bg: string; fg: string; motif: "orb" | "grid" | "wave" | "type" | "stack" | "sun"; span?: "wide" | "tall"; light?: boolean };
241
242const WORK: Work[] = [
243 { title: "Orbit", client: "Lumen Audio", disc: "Identity · 2026", bg: "#1D1B3A", fg: "#B7A6FF", motif: "orb", span: "wide" },
244 { title: "Fieldnotes", client: "Moss & Co.", disc: "Editorial", bg: "#E7E0D0", fg: "#2F4A2A", motif: "type", light: true },
245 { title: "Tidal", client: "Kaimana Swim", disc: "Campaign", bg: "#0E4A5C", fg: "#7FE3E0", motif: "wave", span: "tall" },
246 { title: "Kiln", client: "Hearth Ceramics", disc: "Packaging", bg: "#D9673D", fg: "#FFE3CC", motif: "sun" },
247 { title: "Gridline", client: "Parallel Bank", disc: "Product design", bg: "#F2F2F0", fg: "#141414", motif: "grid", light: true },
248 { title: "Strata", client: "Basalt Records", disc: "Album art", bg: "#121214", fg: "#FF4D2E", motif: "stack", span: "wide" },
249];
250
251function Motif({ w }: { w: Work }) {
252 const c = w.fg;
253 switch (w.motif) {
254 case "orb":
255 return (
256 <>
257 <span className="fd-mcg-demo-m" data-depth="1.6" style={{ background: `radial-gradient(circle at 35% 30%, #fff 0%, ${c} 30%, transparent 70%)`, width: "46%", aspectRatio: "1", borderRadius: "50%", left: "40%", top: "18%" }} />
258 <span className="fd-mcg-demo-m" data-depth="0.6" style={{ border: `1.5px solid ${c}`, width: "70%", aspectRatio: "1", borderRadius: "50%", left: "28%", top: "-6%", opacity: 0.5 }} />
259 </>
260 );
261 case "grid":
262 return (
263 <span className="fd-mcg-demo-m" data-depth="1" style={{ inset: "18%", backgroundImage: `linear-gradient(${c} 2px, transparent 2px), linear-gradient(90deg, ${c} 2px, transparent 2px)`, backgroundSize: "28px 28px", opacity: 0.85 }} />
264 );
265 case "wave":
266 return (
267 <svg className="fd-mcg-demo-m" data-depth="1.2" viewBox="0 0 200 300" preserveAspectRatio="none" style={{ inset: 0 }}>
268 {[0, 1, 2, 3, 4, 5].map((i) => (
269 <path key={i} d={`M0 ${120 + i * 26} C 50 ${100 + i * 26} 100 ${150 + i * 26} 200 ${118 + i * 26}`} fill="none" stroke={c} strokeWidth="2" opacity={1 - i * 0.14} />
270 ))}
271 </svg>
272 );
273 case "type":
274 return (
275 <span className="fd-mcg-demo-m fd-mcg-demo-big" data-depth="1.2" style={{ color: c, left: "10%", top: "16%" }}>
276 Aa
277 </span>
278 );
279 case "sun":
280 return (
281 <>
282 <span className="fd-mcg-demo-m" data-depth="1.4" style={{ background: c, width: "44%", aspectRatio: "1", borderRadius: "50%", left: "28%", top: "16%" }} />
283 <span className="fd-mcg-demo-m" data-depth="0.4" style={{ background: "rgba(0,0,0,.18)", left: 0, right: 0, top: "56%", bottom: 0 }} />
284 </>
285 );
286 default:
287 return (
288 <>
289 {[0, 1, 2].map((i) => (
290 <span key={i} className="fd-mcg-demo-m" data-depth={String(0.6 + i * 0.6)} style={{ background: c, opacity: 0.35 + i * 0.3, left: `${18 + i * 10}%`, top: `${20 + i * 12}%`, width: "46%", height: "22%", borderRadius: 10 }} />
291 ))}
292 </>
293 );
294 }
295}
296
297export function Demo() {
298 return (
299 <div className="fd-mcg-demo">
300 <style>{demoCss}</style>
301 <CursorStage label="View">
302 <header className="fd-mcg-demo-head">
303 <span className="fd-mcg-demo-logo">Kollektiv Nord</span>
304 <nav className="fd-mcg-demo-nav" aria-label="Studio">
305 <a href="#work" className="fd-mcg-demo-link" data-magnetic data-cursor="Work">
306 Work
307 </a>
308 <a href="#about" className="fd-mcg-demo-link" data-magnetic data-cursor="About">
309 Studio
310 </a>
311 <a href="#contact" className="fd-mcg-demo-link" data-magnetic data-cursor="Say hi">
312 Contact
313 </a>
314 </nav>
315 </header>
316 <h1 className="fd-mcg-demo-h">Selected work, 2024–26</h1>
317 <div className="fd-mcg-demo-grid">
318 {WORK.map((w) => (
319 <a key={w.title} href={`#${w.title.toLowerCase()}`} className="fd-mcg-demo-tile" data-magnetic data-span={w.span} style={{ background: w.bg, color: w.fg, ["--scrim" as string]: w.light ? "rgba(255,255,255,.85)" : "rgba(0,0,0,.45)" }}>
320 <span className="fd-mcg-demo-art">
321 <Motif w={w} />
322 </span>
323 <span className="fd-mcg-demo-glare" />
324 <span className="fd-mcg-demo-meta" data-depth="2.2">
325 <span className="fd-mcg-demo-t">{w.title}</span>
326 <span className="fd-mcg-demo-c">
327 {w.client} — {w.disc}
328 </span>
329 </span>
330 </a>
331 ))}
332 </div>
333 </CursorStage>
334 </div>
335 );
336}
337
338const demoCss = `
339.fd-mcg-demo{display:block;margin:0;padding:0;background:#0C0C0E;color:#F2F2F0;min-height:100vh;overflow:clip}
340.fd-mcg-demo,.fd-mcg-demo *{box-sizing:border-box}
341.fd-mcg-demo .fd-mcg{max-width:1240px;margin:0 auto;padding:28px clamp(16px,4vw,48px) 64px}
342.fd-mcg-demo-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 clamp(28px,6vh,56px)}
343.fd-mcg-demo-logo{color:#F2F2F0;font:700 1rem/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.02em}
344.fd-mcg-demo-nav{display:flex;gap:4px;margin:0;padding:0}
345.fd-mcg-demo-link{display:inline-flex;align-items:center;height:36px;padding:0 14px;border-radius:9999px;color:#F2F2F0;text-decoration:none;font:500 0.875rem/1 ui-sans-serif,-apple-system,"Inter",sans-serif;background:rgba(255,255,255,.06)}
346.fd-mcg-demo-link:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
347.fd-mcg-demo-h{margin:0 0 24px;padding:0;color:#F2F2F0;font:600 clamp(2rem,5vw,3.5rem)/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.04em}
348.fd-mcg-demo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:clamp(140px,15vw,210px);gap:14px;margin:0;padding:0}
349.fd-mcg-demo-tile{position:relative;display:block;margin:0;padding:0;border-radius:22px;overflow:hidden;text-decoration:none;isolation:isolate;
350 box-shadow:0 1px 0 rgba(255,255,255,.04) inset;transition:box-shadow 420ms cubic-bezier(0.22,1,0.36,1)}
351.fd-mcg-demo-tile[data-hot]{box-shadow:0 40px 60px -30px rgba(0,0,0,.8),0 0 0 1px rgba(255,255,255,.08) inset;z-index:2}
352.fd-mcg-demo-tile[data-span="wide"]{grid-column:span 2}
353.fd-mcg-demo-tile[data-span="tall"]{grid-row:span 2}
354.fd-mcg-demo-tile:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:3px}
355.fd-mcg-demo-art{position:absolute;inset:0;display:block}
356.fd-mcg-demo-m{position:absolute;display:block;will-change:transform}
357.fd-mcg-demo-big{font:700 clamp(4rem,10vw,9rem)/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.06em}
358.fd-mcg-demo-glare{position:absolute;inset:0;display:block;opacity:0;pointer-events:none;
359 background:radial-gradient(60% 60% at var(--fd-mcg-gx,50%) var(--fd-mcg-gy,50%),rgba(255,255,255,.28),transparent 60%);
360 transition:opacity 240ms cubic-bezier(0.22,1,0.36,1)}
361.fd-mcg-demo-tile[data-hot] .fd-mcg-demo-glare{opacity:1}
362.fd-mcg-demo-tile::after{content:"";position:absolute;left:0;right:0;bottom:0;height:46%;z-index:0;pointer-events:none;
363 background:linear-gradient(to top,var(--scrim,rgba(0,0,0,.42)),transparent)}
364.fd-mcg-demo-meta{position:absolute;left:18px;right:18px;bottom:16px;z-index:1;display:flex;flex-direction:column;gap:3px}
365.fd-mcg-demo-t{color:inherit;font:650 1.25rem/1.1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.02em}
366.fd-mcg-demo-c{color:inherit;opacity:.7;font:500 0.75rem/1.3 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace}
367@media (max-width:720px){
368 .fd-mcg-demo-grid{grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:150px;gap:10px}
369 .fd-mcg-demo-tile[data-span="wide"]{grid-column:span 2}
370 .fd-mcg-demo-grid{grid-auto-flow:dense}
371 .fd-mcg-demo-tile[data-span="wide"]:last-child{grid-column:span 1}
372 .fd-mcg-demo-nav .fd-mcg-demo-link:not(:last-child){display:none}
373 .fd-mcg-demo-tile{border-radius:18px}
374 .fd-mcg-demo-big{font-size:3.5rem}
375 .fd-mcg-demo-meta{left:14px;right:14px;bottom:12px}
376 .fd-mcg-demo-c{display:none}
377}
378`;

More Scroll & Hover Effects. Same file contract.

Scroll Split Card

A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.

Hover Transitions

Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.

Sticky Storytelling

Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.