Scroll Split Card
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Portfolio tiles that tilt and lift toward a spring cursor that morphs from dot to ring to a “View” label.
Browser support · Evergreen browsers with a fine pointer. Touch and reduced motion keep the native cursor and flat tiles.
npx shadcn@latest add https://freedesignmd.com/r/magnetic-cursor-grid.json1/**2 * MagneticCursorGrid — a grid of tiles that tilt and lift toward a custom3 * cursor. The cursor is a spring-driven follower that morphs: a dot in empty4 * space, a ring near tiles, and a filled "View" label over a tile (or any5 * 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 cursor15 * and plain tiles are used.16 * Accessibility: tiles are links/buttons; keyboard focus lifts a tile the same17 * way hover does. The follower is aria-hidden. Reduced motion: no tilt, no18 * 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 // follower109 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 layout114 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 // tiles124 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 corner164 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 Aa277 </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 Work307 </a>308 <a href="#about" className="fd-mcg-demo-link" data-magnetic data-cursor="About">309 Studio310 </a>311 <a href="#contact" className="fd-mcg-demo-link" data-magnetic data-cursor="Say hi">312 Contact313 </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`;A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.
Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.