Magnetic Dock

A macOS-style dock: icons swell with a gaussian falloff, labels float up, launches bounce and running apps get a dot.

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

Browser support · All evergreen browsers. Magnification is pointer-only; keyboard focus magnifies too. Tooltip-on-focus uses :has().

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/magnetic-dock.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/interactions/magnetic-dock.tsx
1/**
2 * MagneticDock — a macOS-style dock. Icons grow with a gaussian falloff
3 * around the cursor, the bar widens with them, labels float above on hover
4 * or focus, launching an app bounces it, and running apps get a dot.
5 *
6 * Props
7 * - apps: { id, label, icon: ReactNode, running?: boolean, badge?: number }[]
8 * - onLaunch?: (id) => void
9 * - 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. ← → Home
16 * End move focus (focus also magnifies), Enter / Space launch. Badges and
17 * running state are in each button's accessible name.
18 * Motion: per-icon spring on scale (380 / 30); layout reflows via width so
19 * neighbours make room. Reduced motion or touch: no magnification, bounce
20 * 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 symmetric
71 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 <li
182 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 <button
190 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`;

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.

Custom Cursor

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

Fisheye Grid

An infinite two-axis tile wall you drag with momentum, with a loupe-like lens that swells tiles around the cursor.