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 macOS-style dock: icons swell with a gaussian falloff, labels float up, launches bounce and running apps get a dot.
Browser support · All evergreen browsers. Magnification is pointer-only; keyboard focus magnifies too. Tooltip-on-focus uses :has().
npx shadcn@latest add https://freedesignmd.com/r/magnetic-dock.json1/**2 * MagneticDock — a macOS-style dock. Icons grow with a gaussian falloff3 * around the cursor, the bar widens with them, labels float above on hover4 * or focus, launching an app bounces it, and running apps get a dot.5 *6 * Props7 * - apps: { id, label, icon: ReactNode, running?: boolean, badge?: number }[]8 * - onLaunch?: (id) => void9 * - size?: number Base icon size in px. Default 52 (44 under 640px).10 * - magnify?: number Peak scale. Default 1.75.11 * - spread?: number Gaussian sigma in icon widths. Default 1.25.12 * - separatorAfter?: number Draw a divider after this index.13 * CSS vars: --fd-dock-bg, --fd-dock-border, --fd-dock-tip-bg, --fd-dock-tip-fg.14 *15 * Accessibility: a real toolbar of <button>s with roving tabindex. ← → Home16 * End move focus (focus also magnifies), Enter / Space launch. Badges and17 * running state are in each button's accessible name.18 * Motion: per-icon spring on scale (380 / 30); layout reflows via width so19 * neighbours make room. Reduced motion or touch: no magnification, bounce20 * becomes a short opacity blink.21 * Browser support: all evergreen browsers (backdrop-filter optional).22 */23import { useEffect, useId, useRef, useState } from "react";24import type { KeyboardEvent, ReactNode } from "react";25 26export type DockApp = { id: string; label: string; icon: ReactNode; running?: boolean; badge?: number };27 28export interface MagneticDockProps {29 apps: DockApp[];30 onLaunch?: (id: string) => void;31 size?: number;32 magnify?: number;33 spread?: number;34 separatorAfter?: number;35 className?: string;36}37 38export function MagneticDock({ apps, onLaunch, size = 52, magnify = 1.75, spread = 1.25, separatorAfter, className }: MagneticDockProps) {39 const bar = useRef<HTMLDivElement>(null);40 const items = useRef<(HTMLLIElement | null)[]>([]);41 const btns = useRef<(HTMLButtonElement | null)[]>([]);42 const [focus, setFocus] = useState(0);43 const [bouncing, setBouncing] = useState<string | null>(null);44 const focusX = useRef<number | null>(null);45 46 useEffect(() => {47 const el = bar.current;48 if (!el) return;49 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;50 const fine = window.matchMedia?.("(hover: hover) and (pointer: fine)").matches;51 if (reduced || !fine) return;52 const n = apps.length;53 const sc = new Float32Array(n).fill(1);54 const vs = new Float32Array(n);55 const tg = new Float32Array(n).fill(1);56 const centers = new Float32Array(n);57 let base = size;58 let mx: number | null = null;59 let raf = 0;60 let last = 0;61 62 // centres measured from the un-magnified layout (offsetLeft at rest is stable enough;63 // we compute from index so magnification never feeds back into the input)64 const measure = () => {65 base = el.clientWidth < 1 ? size : parseFloat(getComputedStyle(el).getPropertyValue("--fd-dock-size")) || size;66 };67 const restCenters = () => {68 if (!items.current[0]) return;69 const r0 = el.getBoundingClientRect();70 // rest layout: icons packed with gap; reconstruct from the bar's centre so it is symmetric71 const gap = parseFloat(getComputedStyle(el.firstElementChild as Element).columnGap || "0") || 0;72 let extra = 0;73 const ws: number[] = [];74 for (let i = 0; i < n; i++) {75 ws.push(base);76 if (separatorAfter !== undefined && i === separatorAfter) extra += 17;77 }78 const total = ws.reduce((a, b) => a + b, 0) + gap * (n - 1) + extra;79 let x = r0.left + r0.width / 2 - total / 2;80 for (let i = 0; i < n; i++) {81 centers[i] = x + base / 2;82 x += base + gap + (separatorAfter !== undefined && i === separatorAfter ? 17 : 0);83 }84 };85 86 const tick = (t: number) => {87 raf = 0;88 const dt = Math.min(0.032, (t - last) / 1000 || 0.016);89 last = t;90 const src = mx ?? focusX.current;91 if (src !== null) restCenters();92 const sig = spread * base;93 let moving = false;94 for (let i = 0; i < n; i++) {95 if (src === null) tg[i] = 1;96 else {97 const d = src - centers[i];98 tg[i] = 1 + (magnify - 1) * Math.exp(-(d * d) / (2 * sig * sig));99 }100 const h = dt / 2;101 for (let k = 0; k < 2; k++) {102 vs[i] += (-520 * (sc[i] - tg[i]) - 36 * vs[i]) * h;103 sc[i] += vs[i] * h;104 }105 if (Math.abs(sc[i] - tg[i]) > 0.001 || Math.abs(vs[i]) > 0.01) moving = true;106 }107 for (let i = 0; i < n; i++) {108 const it = items.current[i];109 if (it) it.style.setProperty("--s", sc[i].toFixed(4));110 }111 if (moving || src !== null) raf = requestAnimationFrame(tick);112 };113 const kick = () => {114 if (!raf) {115 last = performance.now();116 raf = requestAnimationFrame(tick);117 }118 };119 const onMove = (e: PointerEvent) => {120 mx = e.clientX;121 kick();122 };123 const onLeave = () => {124 mx = null;125 kick();126 };127 measure();128 el.addEventListener("pointermove", onMove);129 el.addEventListener("pointerleave", onLeave);130 const onFocusMove = () => kick();131 el.addEventListener("fd-dock-focus", onFocusMove);132 window.addEventListener("resize", measure);133 return () => {134 cancelAnimationFrame(raf);135 el.removeEventListener("pointermove", onMove);136 el.removeEventListener("pointerleave", onLeave);137 el.removeEventListener("fd-dock-focus", onFocusMove);138 window.removeEventListener("resize", measure);139 };140 }, [apps.length, size, magnify, spread, separatorAfter]);141 142 const launch = (id: string) => {143 setBouncing(null);144 requestAnimationFrame(() => setBouncing(id));145 onLaunch?.(id);146 };147 148 const onKey = (e: KeyboardEvent<HTMLDivElement>) => {149 const n = apps.length;150 let next = focus;151 if (e.key === "ArrowRight") next = (focus + 1) % n;152 else if (e.key === "ArrowLeft") next = (focus - 1 + n) % n;153 else if (e.key === "Home") next = 0;154 else if (e.key === "End") next = n - 1;155 else return;156 e.preventDefault();157 setFocus(next);158 btns.current[next]?.focus();159 };160 161 const onFocusBtn = (i: number) => {162 setFocus(i);163 const b = btns.current[i];164 if (b && b.matches(":focus-visible")) {165 const r = b.getBoundingClientRect();166 focusX.current = r.left + r.width / 2;167 bar.current?.dispatchEvent(new Event("fd-dock-focus"));168 }169 };170 const onBlurBtn = () => {171 focusX.current = null;172 bar.current?.dispatchEvent(new Event("fd-dock-focus"));173 };174 175 return (176 <div className={`fd-dock${className ? " " + className : ""}`} style={{ ["--fd-dock-size" as string]: `${size}px`, ["--fd-dock-mag" as string]: magnify }}>177 <style>{css}</style>178 <div ref={bar} className="fd-dock-bar" role="toolbar" aria-label="Dock" onKeyDown={onKey}>179 <ul className="fd-dock-list">180 {apps.map((a, i) => (181 <li182 key={a.id}183 ref={(n) => { items.current[i] = n; }}184 className="fd-dock-item"185 data-sep={separatorAfter === i ? "" : undefined}186 data-bounce={bouncing === a.id ? "" : undefined}187 onAnimationEnd={() => setBouncing((b) => (b === a.id ? null : b))}188 >189 <button190 ref={(n) => { btns.current[i] = n; }}191 type="button"192 className="fd-dock-btn"193 tabIndex={i === focus ? 0 : -1}194 aria-label={`${a.label}${a.badge ? `, ${a.badge} new` : ""}${a.running ? ", running" : ""}`}195 onClick={() => launch(a.id)}196 onFocus={() => onFocusBtn(i)}197 onBlur={onBlurBtn}198 >199 <span className="fd-dock-icon" aria-hidden="true">{a.icon}</span>200 {a.badge ? <span className="fd-dock-badge" aria-hidden="true">{a.badge > 99 ? "99+" : a.badge}</span> : null}201 </button>202 <span className="fd-dock-tip" aria-hidden="true">{a.label}</span>203 <span className="fd-dock-dot" data-on={a.running ? "" : undefined} aria-hidden="true" />204 </li>205 ))}206 </ul>207 </div>208 </div>209 );210}211 212const css = `213.fd-dock,.fd-dock *,.fd-dock *::before,.fd-dock *::after{box-sizing:border-box}214.fd-dock{--fd-dock-bg:rgba(246,246,248,.42);--fd-dock-border:rgba(255,255,255,.45);--fd-dock-tip-bg:rgba(28,28,34,.86);--fd-dock-tip-fg:#F5F5F7;215 --fd-dock-pad:8px;display:flex;justify-content:center;margin:0;padding:0;font:500 13px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",sans-serif}216.fd-dock-bar{position:relative;display:flex;align-items:flex-end;margin:0;padding:var(--fd-dock-pad) 10px;border-radius:calc(var(--fd-dock-size) * .44 + var(--fd-dock-pad));217 background:var(--fd-dock-bg);-webkit-backdrop-filter:blur(28px) saturate(1.8);backdrop-filter:blur(28px) saturate(1.8);218 box-shadow:inset 0 0 0 1px var(--fd-dock-border),inset 0 1px 0 rgba(255,255,255,.5),0 20px 50px -18px rgba(10,10,20,.5),0 2px 8px rgba(10,10,20,.12)}219.fd-dock-list{display:flex;align-items:flex-end;gap:6px;margin:0;padding:0;list-style:none}220.fd-dock-item{--s:1;position:relative;display:flex;flex-direction:column;align-items:center;margin:0;padding:0;list-style:none;221 width:calc(var(--fd-dock-size) * var(--s));height:var(--fd-dock-size);will-change:width}222.fd-dock-item[data-sep]{margin-right:17px}223.fd-dock-item[data-sep]::after{content:"";position:absolute;right:-12px;bottom:4px;width:1px;height:calc(var(--fd-dock-size) - 8px);background:rgba(20,20,30,.18)}224.fd-dock-btn{appearance:none;-webkit-appearance:none;position:absolute;left:50%;bottom:0;display:block;width:var(--fd-dock-size);height:var(--fd-dock-size);margin:0;padding:0;border:0;background:none;225 cursor:pointer;transform-origin:50% 100%;transform:translateX(-50%) scale(var(--s));border-radius:calc(var(--fd-dock-size) * .23);-webkit-tap-highlight-color:transparent}226.fd-dock-btn:focus-visible{outline:2px solid rgba(10,132,255,.85);outline-offset:3px}227.fd-dock-icon{display:block;width:100%;height:100%;margin:0;padding:0;border-radius:inherit;overflow:hidden;228 box-shadow:0 1px 1px rgba(0,0,0,.12),0 4px 10px -4px rgba(0,0,0,.35);transition:filter 120ms}229.fd-dock-icon svg{display:block;width:100%;height:100%}230.fd-dock-btn:active .fd-dock-icon{filter:brightness(.78)}231.fd-dock-badge{position:absolute;top:-4px;right:-6px;min-width:20px;height:20px;margin:0;padding:0 6px;border-radius:9999px;background:#FF3B30;color:#fff;232 font:600 12px/20px ui-sans-serif,-apple-system,"SF Pro Text",sans-serif;text-align:center;box-shadow:0 1px 2px rgba(0,0,0,.25)}233.fd-dock-tip{position:absolute;left:50%;bottom:calc(var(--fd-dock-size) * var(--s) + 14px);margin:0;padding:6px 10px;border-radius:8px;background:var(--fd-dock-tip-bg);color:var(--fd-dock-tip-fg);234 font:500 13px/1 ui-sans-serif,-apple-system,"SF Pro Text","Inter",sans-serif;letter-spacing:-.005em;white-space:nowrap;pointer-events:none;235 opacity:0;transform:translate(-50%,4px);transition:opacity 120ms cubic-bezier(.22,1,.36,1),transform 240ms cubic-bezier(.22,1,.36,1);236 -webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}237.fd-dock-tip::after{content:"";position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;background:inherit;transform:rotate(45deg);border-radius:1px}238.fd-dock-item:hover .fd-dock-tip,.fd-dock-item:has(.fd-dock-btn:focus-visible) .fd-dock-tip{opacity:1;transform:translate(-50%,0)}239.fd-dock-dot{position:absolute;left:50%;bottom:-7px;width:4px;height:4px;margin-left:-2px;border-radius:50%;background:rgba(20,20,30,.72);opacity:0;transition:opacity 240ms}240.fd-dock-dot[data-on]{opacity:1}241.fd-dock-item[data-bounce] .fd-dock-btn{animation:fd-dock-bounce 1150ms cubic-bezier(.3,0,.5,1)}242@keyframes fd-dock-bounce{243 0%{translate:0 0}18%{translate:0 -34%;animation-timing-function:cubic-bezier(.5,0,.7,1)}36%{translate:0 0;animation-timing-function:cubic-bezier(.3,0,.5,1)}244 52%{translate:0 -20%;animation-timing-function:cubic-bezier(.5,0,.7,1)}68%{translate:0 0;animation-timing-function:cubic-bezier(.3,0,.5,1)}82%{translate:0 -7%}100%{translate:0 0}}245@media (max-width:640px){.fd-dock{--fd-dock-size:40px!important}.fd-dock-list{gap:4px}.fd-dock-bar{padding:6px 8px}}246@media (prefers-reduced-motion:reduce){.fd-dock-item[data-bounce] .fd-dock-btn{animation:fd-dock-blink 240ms linear}.fd-dock-tip{transition:none}}247@keyframes fd-dock-blink{50%{opacity:.5}}248@media (prefers-reduced-transparency:reduce){.fd-dock{--fd-dock-bg:#ECECF0}}249@media (prefers-contrast:more){.fd-dock{--fd-dock-bg:#ECECF0;--fd-dock-border:#1C1C22}}250`;251 252/* ───────────────────────── original app icons ───────────────────────── */253 254const Icon = ({ bg, children }: { bg: [string, string]; children: ReactNode }) => {255 const id = `fd-dock${useId().replace(/[^a-zA-Z0-9]/g, "")}`;256 return (257 <svg viewBox="0 0 64 64">258 <defs>259 <linearGradient id={id} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={bg[0]} /><stop offset="1" stopColor={bg[1]} /></linearGradient>260 </defs>261 <rect width="64" height="64" fill={`url(#${id})`} />262 {children}263 <rect width="64" height="32" fill="#fff" opacity=".06" />264 </svg>265 );266};267 268const ICONS: Record<string, ReactNode> = {269 finder: (270 <Icon bg={["#7CC4FF", "#2F7BF6"]}>271 <path d="M32 0v64" stroke="#1D4FB8" strokeWidth="0" />272 <rect x="32" width="32" height="64" fill="#EAF4FF" />273 <circle cx="22" cy="26" r="2.6" fill="#0B2A66" /><circle cx="42" cy="26" r="2.6" fill="#0B2A66" />274 <path d="M32 12c-3 8-4 18-1 28h-5" fill="none" stroke="#0B2A66" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />275 <path d="M18 44c8 6 20 6 28 0" fill="none" stroke="#0B2A66" strokeWidth="2.4" strokeLinecap="round" />276 </Icon>277 ),278 mail: (279 <Icon bg={["#6FD3FF", "#1C8BF0"]}>280 <rect x="12" y="19" width="40" height="27" rx="4" fill="#fff" />281 <path d="M13 21l19 14 19-14" fill="none" stroke="#1C8BF0" strokeWidth="2.6" strokeLinejoin="round" />282 </Icon>283 ),284 calendar: (285 <Icon bg={["#FFFFFF", "#ECECEF"]}>286 <rect width="64" height="18" fill="#FF3B30" />287 <text x="32" y="13" textAnchor="middle" fill="#fff" style={{ font: '700 9px ui-sans-serif,-apple-system,"SF Pro Text",sans-serif', letterSpacing: ".06em" }}>NOV</text>288 <text x="32" y="50" textAnchor="middle" fill="#1C1C22" style={{ font: '300 30px "SF Pro Display",ui-sans-serif,-apple-system,sans-serif', letterSpacing: "-.04em" }}>14</text>289 </Icon>290 ),291 notes: (292 <Icon bg={["#FFF3B0", "#FFE16A"]}>293 <rect width="64" height="15" fill="#F5C518" />294 {[26, 34, 42, 50].map((y) => <rect key={y} x="11" y={y} width={y === 50 ? 26 : 42} height="2" rx="1" fill="#B79A3A" opacity=".55" />)}295 </Icon>296 ),297 board: (298 <Icon bg={["#B48CFF", "#6B3DF0"]}>299 <rect x="11" y="13" width="12" height="38" rx="3" fill="#fff" opacity=".95" />300 <rect x="26" y="13" width="12" height="26" rx="3" fill="#fff" opacity=".8" />301 <rect x="41" y="13" width="12" height="32" rx="3" fill="#fff" opacity=".65" />302 </Icon>303 ),304 chat: (305 <Icon bg={["#5BF08A", "#16B84B"]}>306 <path d="M32 14c11 0 20 7 20 16s-9 16-20 16c-2.4 0-4.6-.3-6.6-.9L16 49l2.6-7.4C14.5 38.6 12 34.5 12 30c0-9 9-16 20-16z" fill="#fff" />307 </Icon>308 ),309 docs: (310 <Icon bg={["#2B2B33", "#121216"]}>311 <path d="M20 12h17l9 9v31H20z" fill="#F5F5F7" />312 <path d="M37 12v9h9" fill="#C9C9D1" />313 {[30, 36, 42].map((y) => <rect key={y} x="25" y={y} width={y === 42 ? 12 : 16} height="2.4" rx="1.2" fill="#5E5CE6" />)}314 </Icon>315 ),316 music: (317 <Icon bg={["#FF7A9A", "#F5294F"]}>318 <path d="M26 44V20l18-4v24" fill="none" stroke="#fff" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round" />319 <ellipse cx="22" cy="44" rx="5" ry="4" fill="#fff" /><ellipse cx="40" cy="40" rx="5" ry="4" fill="#fff" />320 </Icon>321 ),322 settings: (323 <Icon bg={["#A9AAB4", "#6C6D78"]}>324 <g transform="translate(32 32)">325 {Array.from({ length: 12 }, (_, k) => <rect key={k} x="-2.6" y="-21" width="5.2" height="8" rx="1.4" fill="#E8E8ED" transform={`rotate(${k * 30})`} />)}326 <circle r="14" fill="#E8E8ED" /><circle r="6.5" fill="#6C6D78" />327 </g>328 </Icon>329 ),330 trash: (331 <Icon bg={["#E9EEF4", "#C9D2DC"]}>332 <path d="M20 22h24l-2.5 28a3 3 0 0 1-3 2.7H25.5a3 3 0 0 1-3-2.7z" fill="#fff" stroke="#93A0AF" strokeWidth="1.6" />333 {[27, 32, 37].map((x) => <path key={x} d={`M${x} 27v20`} stroke="#B3BECB" strokeWidth="1.4" />)}334 <rect x="17" y="18" width="30" height="5" rx="2" fill="#AAB6C3" />335 </Icon>336 ),337};338 339/* ───────────────────────────── demo ───────────────────────────── */340 341const APPS: DockApp[] = [342 { id: "finder", label: "Files", icon: ICONS.finder, running: true },343 { id: "mail", label: "Mail", icon: ICONS.mail, running: true, badge: 12 },344 { id: "calendar", label: "Calendar", icon: ICONS.calendar, running: true },345 { id: "notes", label: "Notes", icon: ICONS.notes },346 { id: "board", label: "Boards", icon: ICONS.board, running: true },347 { id: "chat", label: "Threads", icon: ICONS.chat, badge: 3 },348 { id: "docs", label: "Docs", icon: ICONS.docs },349 { id: "music", label: "Focus Radio", icon: ICONS.music },350 { id: "settings", label: "Settings", icon: ICONS.settings },351 { id: "trash", label: "Trash", icon: ICONS.trash },352];353 354export function Demo() {355 const [apps, setApps] = useState(APPS);356 const [active, setActive] = useState("Boards");357 const launch = (id: string) => {358 setApps((as) => as.map((a) => (a.id === id ? { ...a, running: true, badge: undefined } : a)));359 setActive(APPS.find((a) => a.id === id)?.label ?? "");360 };361 return (362 <div className="fd-dock-demo">363 <style>{demoCss}</style>364 <div className="fd-dock-demo-wall" aria-hidden="true" />365 <header className="fd-dock-demo-menu">366 <svg className="fd-dock-demo-logo" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M8 1.5a6.5 6.5 0 0 1 0 13z" fill="currentColor" /></svg>367 <strong>{active}</strong>368 <span>File</span><span>Edit</span><span>View</span><span>Window</span>369 <span className="fd-dock-demo-clock">Sat 14 Nov 09:41</span>370 </header>371 <main className="fd-dock-demo-desk">372 <section className="fd-dock-demo-win" aria-label="Today">373 <div className="fd-dock-demo-tl" aria-hidden="true"><i /><i /><i /></div>374 <p className="fd-dock-demo-k">Orbit Workspace · Today</p>375 <h1 className="fd-dock-demo-h1">3 meetings, 1 deep-work block, 12 unread.</h1>376 <ul className="fd-dock-demo-agenda">377 <li><time>10:00</time> Q4 roadmap review <span>Boards</span></li>378 <li><time>13:30</time> Design crit — onboarding v3 <span>Docs</span></li>379 <li><time>15:00</time> Focus · no notifications <span>Focus Radio</span></li>380 </ul>381 </section>382 </main>383 <div className="fd-dock-demo-dockwrap">384 <MagneticDock apps={apps} onLaunch={launch} separatorAfter={8} />385 </div>386 </div>387 );388}389 390const demoCss = `391.fd-dock-demo,.fd-dock-demo *,.fd-dock-demo *::before,.fd-dock-demo *::after{box-sizing:border-box}392.fd-dock-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;393 position:relative;display:flex;flex-direction:column;min-height:100vh;margin:0;padding:0;overflow:hidden;color:#1C1C22;font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased;background:#9AB6D8}394.fd-dock-demo-wall{position:absolute;inset:0;pointer-events:none;395 background:radial-gradient(70% 60% at 15% 20%,#F8C8A8 0%,transparent 60%),radial-gradient(60% 70% at 85% 30%,#B9A4F2 0%,transparent 62%),396 radial-gradient(80% 60% at 50% 100%,#6E9BE0 0%,transparent 70%),linear-gradient(180deg,#E9D6E6,#A7BDE6)}397.fd-dock-demo-menu{position:relative;display:flex;align-items:center;gap:18px;height:30px;margin:0;padding:0 16px;background:rgba(255,255,255,.35);398 -webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);font:500 13px/1 var(--d-font);color:#1C1C22}399.fd-dock-demo-menu strong{font-weight:700}400.fd-dock-demo-logo{display:block}401.fd-dock-demo-clock{margin-left:auto}402.fd-dock-demo-desk{position:relative;flex:1;display:flex;align-items:flex-start;justify-content:center;margin:0;padding:clamp(24px,7vh,72px) 16px 0}403.fd-dock-demo-win{width:100%;max-width:560px;margin:0;padding:22px 26px 26px;border-radius:20px;background:rgba(255,255,255,.78);-webkit-backdrop-filter:blur(30px);backdrop-filter:blur(30px);404 box-shadow:0 0 0 1px rgba(255,255,255,.6),0 30px 60px -24px rgba(20,30,60,.45)}405.fd-dock-demo-tl{display:flex;gap:8px;margin:0 0 18px}406.fd-dock-demo-tl i{display:block;width:12px;height:12px;border-radius:50%;background:#FF5F57}407.fd-dock-demo-tl i:nth-child(2){background:#FEBC2E}.fd-dock-demo-tl i:nth-child(3){background:#28C840}408.fd-dock-demo-k{margin:0 0 6px;color:#6B6B76;font:500 13px/1 var(--d-font)}409.fd-dock-demo-h1{margin:0;color:#1C1C22;font:600 clamp(24px,3vw,32px)/1.1 var(--d-disp);letter-spacing:-.025em;text-wrap:balance}410.fd-dock-demo-agenda{margin:20px 0 0;padding:0;list-style:none}411.fd-dock-demo-agenda li{display:flex;align-items:center;gap:14px;margin:0;padding:12px 0;border-top:1px solid rgba(28,28,34,.08);font:500 15px/1.3 var(--d-font)}412.fd-dock-demo-agenda time{width:44px;color:#6B6B76;font:500 13px/1 ui-monospace,"SF Mono",Menlo,monospace}413.fd-dock-demo-agenda span{margin-left:auto;color:#6B6B76;font:400 13px/1 var(--d-font)}414.fd-dock-demo-dockwrap{position:relative;display:flex;justify-content:center;margin:0;padding:96px 12px 14px}415@media (max-width:640px){.fd-dock-demo-menu span:not(.fd-dock-demo-clock):not(.fd-dock-demo-logo){display:none}}416`;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.
An infinite two-axis tile wall you drag with momentum, with a loupe-like lens that swells tiles around the cursor.