Magnetic Nav Dock
A macOS-style dock where tiles swell on a smooth falloff around the pointer and push their neighbours aside. Crafted SVG app tiles, a label that glides between them, a sheen that follows the cursor, bounce on click and full keyboard support. No dependencies.
DOM + CSS0 deps1 file · 22 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
export type MagneticNavDockProps = {
/**
* One entry per tile. Strings (an emoji, a word like "home", or a short label) and any ReactNode are accepted.
* Leave it out for the five built-in tiles: home, search, messages, files, settings.
*/
icons?: ReactNode[];
/** Tooltip and accessible name per tile. Falls back to a name derived from the icon. */
labels?: string[];
/** Optional href per tile. A tile with an http(s), mailto, hash or relative href renders as a link. */
hrefs?: (string | undefined)[];
/** Resting tile size in px. */
baseSize?: number;
/** Tile size under the pointer in px. */
maxSize?: number;
/** Gap between tiles at rest, in px. */
gap?: number;
/** Half-width of the magnet field around the pointer, in px. */
reach?: number;
/** Index marked as current at first (uncontrolled). */
defaultActive?: number;
/** Controlled current index. */
activeIndex?: number;
/** Called when a tile is activated (click, tap, Enter, Space). */
onSelect?: (index: number, label: string) => void;
/** Accessible name of the nav landmark. */
ariaLabel?: string;
/** Goes on the outer element (position, margins). */
className?: string;
};
type Glyph = "home" | "search" | "messages" | "files" | "settings";
const DEFAULT_ICONS: ReactNode[] = ["home", "search", "messages", "files", "settings"];
const GLYPH_NAMES: Record<string, Glyph> = {
home: "home",
"\u{1F3E0}": "home",
search: "search",
"\u{1F50D}": "search",
messages: "messages",
message: "messages",
chat: "messages",
"\u{1F4AC}": "messages",
files: "files",
file: "files",
folder: "files",
"\u{1F4C1}": "files",
settings: "settings",
"⚙": "settings",
};
const GLYPH_LABEL: Record<Glyph, string> = {
home: "Home",
search: "Search",
messages: "Messages",
files: "Files",
settings: "Settings",
};
const TILE_FILL: Record<Glyph | "plain", string> = {
home: "linear-gradient(160deg, #6d97ff 0%, #3558ea 100%)",
search: "linear-gradient(160deg, #3ddbc8 0%, #0c8a86 100%)",
messages: "linear-gradient(160deg, #6be896 0%, #1b9d52 100%)",
files: "linear-gradient(160deg, #ffcb55 0%, #ec7d17 100%)",
settings: "linear-gradient(160deg, #9aa0ad 0%, #474b56 100%)",
plain: "linear-gradient(160deg, #4a4e5a 0%, #20222a 100%)",
};
const TILE_EDGE =
"inset 0 1px 0 rgba(255,255,255,0.38), inset 0 0 0 1px rgba(255,255,255,0.14), inset 0 -10px 14px -8px rgba(0,0,0,0.38), 0 8px 16px -6px rgba(0,0,0,0.6)";
const PAD_X = 16;
const PAD_T = 10;
const PAD_B = 16;
const GRAVITY = 2600;
const SPRING_K = 560;
const SPRING_C = 42;
const CSS = [
".mnd-tile{transition:transform 140ms cubic-bezier(.2,.8,.2,1)}",
".mnd-btn:active .mnd-tile{transform:scale(.93)}",
".mnd-btn:focus-visible{outline:2px solid #fff!important;outline-offset:3px!important;border-radius:27%!important}",
"@media (prefers-reduced-motion:reduce){.mnd-tile{transition:none}.mnd-btn:active .mnd-tile{transform:none}}",
].join("");
function glyphOf(icon: ReactNode): Glyph | null {
if (typeof icon !== "string") return null;
const key = icon.replace(/️/g, "").trim().toLowerCase();
return GLYPH_NAMES[key] ?? null;
}
function labelOf(icon: ReactNode, i: number, labels?: string[]): string {
const given = labels?.[i];
if (given) return given;
const g = glyphOf(icon);
if (g) return GLYPH_LABEL[g];
if (typeof icon === "string" && icon.trim()) return icon.trim();
return "Item " + (i + 1);
}
function safeHref(h: string | undefined): string | undefined {
if (!h || /\\/.test(h)) return undefined;
if (/^(https?:|mailto:|tel:|#|\/(?!\/)|\.{0,2}\/)/i.test(h)) return h;
return undefined;
}
function GlyphArt({ glyph }: { glyph: Glyph }) {
const common = {
fill: "none",
stroke: "#fff",
strokeWidth: 2.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
return (
<svg viewBox="0 0 48 48" width="100%" height="100%" aria-hidden focusable="false" style={{ display: "block", filter: "drop-shadow(0 1.5px 1px rgba(0,0,0,0.28))" }}>
{glyph === "home" && (
<g {...common}>
<path d="M11.5 23.5 24 12.5l12.5 11" />
<path d="M15.5 21.5V35h17V21.5" />
<path d="M21 35v-8h6v8" />
</g>
)}
{glyph === "search" && (
<g {...common}>
<circle cx="21.5" cy="21.5" r="9" />
<path d="m28.5 28.5 8 8" />
</g>
)}
{glyph === "messages" && (
<g>
<path
d="M14 16.5A4.5 4.5 0 0 1 18.5 12h11a4.5 4.5 0 0 1 4.5 4.5v8a4.5 4.5 0 0 1-4.5 4.5H25l-6.5 5v-5A4.5 4.5 0 0 1 14 24.5Z"
{...common}
/>
<circle cx="20" cy="20.5" r="1.7" fill="#fff" />
<circle cx="24" cy="20.5" r="1.7" fill="#fff" />
<circle cx="28" cy="20.5" r="1.7" fill="#fff" />
</g>
)}
{glyph === "files" && (
<path
d="M11.5 18.5a3 3 0 0 1 3-3h6.2l3 3.6h10.8a3 3 0 0 1 3 3V32a3 3 0 0 1-3 3h-20a3 3 0 0 1-3-3Z"
{...common}
fill="rgba(255,255,255,0.2)"
/>
)}
{glyph === "settings" && (
<g>
<g {...common} strokeWidth={2.4} opacity={0.6}>
<path d="M12 17h24M12 24h24M12 31h24" />
</g>
<circle cx="19" cy="17" r="3.4" fill="#fff" />
<circle cx="30" cy="24" r="3.4" fill="#fff" />
<circle cx="22" cy="31" r="3.4" fill="#fff" />
</g>
)}
</svg>
);
}
function Tile({ icon }: { icon: ReactNode }) {
const g = glyphOf(icon);
return (
<>
<span aria-hidden className="absolute inset-0 block overflow-hidden" style={{ borderRadius: "inherit", background: TILE_FILL[g ?? "plain"], boxShadow: TILE_EDGE }}>
<span
className="absolute inset-x-0 top-0 block"
style={{ height: "52%", background: "linear-gradient(180deg, rgba(255,255,255,0.22), rgba(255,255,255,0))" }}
/>
</span>
<span aria-hidden className="relative flex h-full w-full items-center justify-center" style={{ padding: g ? "8%" : 0 }}>
{g ? (
<GlyphArt glyph={g} />
) : (
<span style={{ color: "#fff", fontSize: "0.46em", fontWeight: 600, lineHeight: 1, letterSpacing: "-0.01em" }}>
{typeof icon === "string" ? Array.from(icon.trim()).slice(0, 2).join("") : icon}
</span>
)}
</span>
</>
);
}
type Input = { px: number | null; fx: number | null; fi: number; flash: number; bounce: number[] };
export function MagneticNavDock({
icons = DEFAULT_ICONS,
labels,
hrefs,
baseSize = 44,
maxSize = 72,
gap = 12,
reach = 120,
defaultActive = 0,
activeIndex,
onSelect,
ariaLabel = "Dock",
className,
}: MagneticNavDockProps) {
const n = icons.length;
const base = Math.max(16, baseSize);
const max = Math.max(base, maxSize);
const restW = PAD_X * 2 + n * base + Math.max(0, n - 1) * gap;
const barH = PAD_T + base + PAD_B;
const [innerActive, setInnerActive] = useState(defaultActive);
const [roving, setRoving] = useState(Math.min(Math.max(0, defaultActive), Math.max(0, n - 1)));
const active = activeIndex ?? innerActive;
const rootRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const liRefs = useRef<(HTMLLIElement | null)[]>([]);
const liftRefs = useRef<(HTMLDivElement | null)[]>([]);
const tileRefs = useRef<(HTMLSpanElement | null)[]>([]);
const ctlRefs = useRef<(HTMLElement | null)[]>([]);
const input = useRef<Input>({ px: null, fx: null, fi: -1, flash: -1, bounce: [] });
const cfg = useRef({ base, max, gap, reach: Math.max(24, reach), reduced: false, restW, labels: [] as string[] });
const wake = useRef<() => void>(() => {});
const lastType = useRef("mouse");
const labelList = icons.map((ic, i) => labelOf(ic, i, labels));
const labelKey = labelList.join("\u0001");
useEffect(() => {
cfg.current = { ...cfg.current, base, max, gap, reach: Math.max(24, reach), restW, labels: labelKey ? labelKey.split("\u0001") : [] };
wake.current();
}, [base, max, gap, reach, restW, labelKey]);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const set = () => {
cfg.current.reduced = mq.matches;
wake.current();
};
set();
mq.addEventListener("change", set);
return () => mq.removeEventListener("change", set);
}, []);
useEffect(() => {
const root = rootRef.current;
const bar = barRef.current;
const tip = tipRef.current;
if (!root || !bar || !tip) return;
const s = new Float64Array(n).fill(cfg.current.base);
const sv = new Float64Array(n);
const hot = new Float64Array(n);
const ly = new Float64Array(n);
const lv = new Float64Array(n);
const xs = new Float64Array(n);
let raf = 0;
let last = 0;
let tipX = 0;
let tipB = 0;
let tipO = 0;
let tipIdx = -1;
let flashIdx = -1;
let flashUntil = 0;
let gx = 0;
let go = 0;
const frame = (t: number) => {
raf = 0;
const dt = last ? Math.min((t - last) / 1000, 1 / 30) : 1 / 60;
last = t;
const c = cfg.current;
const inp = input.current;
const anchor = inp.px ?? inp.fx;
let moving = false;
if (inp.flash >= 0) {
flashIdx = inp.flash;
flashUntil = t + 1200;
inp.flash = -1;
}
while (inp.bounce.length) {
const bi = inp.bounce.pop();
if (bi === undefined || bi >= n || c.reduced) continue;
lv[bi] = Math.sqrt(2 * GRAVITY * 0.5 * (s[bi] ?? c.base));
ly[bi] = 0.01;
}
const rc = (i: number) => PAD_X + i * (c.base + c.gap) + c.base / 2;
let near = -1;
let nearD = Infinity;
let sum = 0;
for (let i = 0; i < n; i++) {
const d = anchor === null ? Infinity : Math.abs(anchor - rc(i));
if (d < nearD) {
nearD = d;
near = i;
}
const u = Math.max(0, 1 - d / c.reach);
const hf = anchor === null ? 0 : u * u * (3 - 2 * u);
const f = c.reduced ? 0 : hf;
const target = c.base + (c.max - c.base) * f;
let p = s[i] ?? c.base;
let v = sv[i] ?? 0;
const h = dt / 2;
for (let k = 0; k < 2; k++) {
v += (SPRING_K * (target - p) - SPRING_C * v) * h;
p += v * h;
}
if (Math.abs(target - p) < 0.02 && Math.abs(v) < 0.4) {
p = target;
v = 0;
} else moving = true;
s[i] = p;
sv[i] = v;
sum += p;
const hh = hot[i] ?? 0;
const nh = hh + (hf - hh) * (1 - Math.exp(-dt * 16));
hot[i] = Math.abs(hf - nh) < 0.003 ? hf : nh;
if (hot[i] !== hf) moving = true;
let y = ly[i] ?? 0;
let yv = lv[i] ?? 0;
if (y > 0 || yv > 0) {
yv -= GRAVITY * dt;
y += yv * dt;
if (y <= 0) {
y = 0;
yv = yv < -70 ? -yv * 0.42 : 0;
}
moving = true;
}
ly[i] = y;
lv[i] = yv;
}
const W = PAD_X * 2 + sum + Math.max(0, n - 1) * c.gap;
const barLeft = (c.restW - W) / 2;
bar.style.width = W + "px";
bar.style.left = barLeft + "px";
let x = PAD_X;
for (let i = 0; i < n; i++) {
const size = s[i] ?? c.base;
xs[i] = x;
const li = liRefs.current[i];
if (li) {
li.style.left = x + "px";
li.style.width = size + "px";
li.style.height = size + "px";
li.style.fontSize = size + "px";
}
const lift = liftRefs.current[i];
if (lift) lift.style.transform = "translate3d(0," + -(ly[i] ?? 0) + "px,0)";
const tile = tileRefs.current[i];
if (tile) tile.style.filter = "brightness(" + (1 + 0.1 * (hot[i] ?? 0)).toFixed(3) + ")";
x += size + c.gap;
}
// tooltip
let idx = -1;
if (inp.px !== null) idx = near;
else if (inp.fx !== null) idx = inp.fi;
else if (t < flashUntil) idx = flashIdx;
if (idx >= n) idx = -1;
const wasHidden = tipO < 0.03;
if (idx >= 0) {
if (idx !== tipIdx) {
tip.textContent = c.labels[idx] ?? "";
tipIdx = idx;
}
const tx = (xs[idx] ?? 0) + (s[idx] ?? c.base) / 2;
const tb = PAD_B + (s[idx] ?? c.base) + (ly[idx] ?? 0) + 10;
if (wasHidden || c.reduced) {
tipX = tx;
tipB = tb;
} else {
const k = 1 - Math.exp(-dt * 26);
tipX += (tx - tipX) * k;
tipB += (tb - tipB) * k;
if (Math.abs(tx - tipX) > 0.3 || Math.abs(tb - tipB) > 0.3) moving = true;
}
}
const to = idx >= 0 ? 1 : 0;
tipO += (to - tipO) * (1 - Math.exp(-dt * 18));
if (Math.abs(to - tipO) < 0.01) tipO = to;
else moving = true;
if (t < flashUntil) moving = true;
tip.style.opacity = String(tipO);
tip.style.left = tipX + "px";
tip.style.bottom = tipB + "px";
tip.style.transform = "translate3d(-50%," + (1 - tipO) * 6 + "px,0)";
// surface sheen under the pointer
if (anchor !== null) {
const gt = anchor - barLeft;
gx = go < 0.03 ? gt : gx + (gt - gx) * (1 - Math.exp(-dt * 30));
if (Math.abs(gt - gx) > 0.3) moving = true;
}
const gto = anchor === null ? 0 : 1;
go += (gto - go) * (1 - Math.exp(-dt * 10));
if (Math.abs(gto - go) < 0.01) go = gto;
else moving = true;
bar.style.setProperty("--mnd-gx", gx.toFixed(1) + "px");
bar.style.setProperty("--mnd-go", go.toFixed(3));
if (moving) raf = requestAnimationFrame(frame);
else last = 0;
};
const start = () => {
if (!raf) raf = requestAnimationFrame(frame);
};
wake.current = start;
const onMove = (e: PointerEvent) => {
const inp = input.current;
if (e.pointerType === "touch") {
if (inp.px !== null) {
inp.px = null;
start();
}
return;
}
const r = root.getBoundingClientRect();
if (!r.width) return;
const k = root.offsetWidth / r.width;
const lx = (e.clientX - r.left) * k;
const lyy = (e.clientY - r.top) * k;
const c = cfg.current;
const inside = lx > -24 && lx < c.restW + 24 && lyy > -(c.max - c.base) - 40 && lyy < c.base + PAD_T + PAD_B + 16;
const next = inside ? lx : null;
if (next !== inp.px) {
inp.px = next;
start();
}
};
const onLeave = () => {
if (input.current.px !== null) {
input.current.px = null;
start();
}
};
document.addEventListener("pointermove", onMove, { passive: true });
document.documentElement.addEventListener("pointerleave", onLeave);
window.addEventListener("blur", onLeave);
start();
return () => {
cancelAnimationFrame(raf);
wake.current = () => {};
document.removeEventListener("pointermove", onMove);
document.documentElement.removeEventListener("pointerleave", onLeave);
window.removeEventListener("blur", onLeave);
};
}, [n]);
const focusAt = (i: number) => {
const j = (i + n) % n;
ctlRefs.current[j]?.focus();
};
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
const cur = ctlRefs.current.findIndex((el) => el === document.activeElement);
if (cur < 0) return;
if (e.key === "ArrowRight" || e.key === "ArrowUp") focusAt(cur + 1);
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") focusAt(cur - 1);
else if (e.key === "Home") focusAt(0);
else if (e.key === "End") focusAt(n - 1);
else return;
e.preventDefault();
};
const activate = (i: number, detail: number) => {
if (activeIndex === undefined) setInnerActive(i);
onSelect?.(i, labelList[i] ?? "");
input.current.bounce.push(i);
if (lastType.current === "touch" || (detail === 0 && input.current.fx === null)) input.current.flash = i;
wake.current();
};
return (
<div ref={rootRef} className={"relative shrink-0 " + (className ?? "")} style={{ width: restW, height: barH }}>
<style>{CSS}</style>
<nav aria-label={ariaLabel} className="contents">
<div
ref={barRef}
onKeyDown={onKeyDown}
className="absolute bottom-0"
style={
{
left: 0,
width: restW,
height: barH,
borderRadius: Math.round(base * 0.56),
background: "linear-gradient(180deg, rgba(34,36,43,0.86), rgba(14,15,19,0.9))",
boxShadow:
"inset 0 0 0 1px rgba(255,255,255,0.1), inset 0 1px 0 rgba(255,255,255,0.18), 0 22px 44px -14px rgba(0,0,0,0.65), 0 6px 14px -4px rgba(0,0,0,0.4)",
backdropFilter: "blur(22px) saturate(1.5)",
WebkitBackdropFilter: "blur(22px) saturate(1.5)",
"--mnd-gx": "0px",
"--mnd-go": "0",
} as CSSProperties
}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 block"
style={{
borderRadius: "inherit",
background: "radial-gradient(160px 70px at var(--mnd-gx) 0%, rgba(255,255,255,calc(var(--mnd-go) * 0.2)), transparent 70%)",
}}
/>
<ul role="list" className="m-0 list-none p-0">
{icons.map((icon, i) => {
const label = labelList[i] ?? "";
const isActive = i === active;
const href = safeHref(hrefs?.[i]);
const left = PAD_X + i * (base + gap);
const ctlProps = {
ref: (el: HTMLElement | null) => {
ctlRefs.current[i] = el;
},
"aria-label": label,
"aria-current": isActive ? ("page" as const) : undefined,
tabIndex: i === roving ? 0 : -1,
className: "mnd-btn relative block h-full w-full cursor-pointer border-0 bg-transparent p-0 no-underline",
style: { borderRadius: "27%", outline: "none", WebkitTapHighlightColor: "transparent", touchAction: "manipulation" } as CSSProperties,
onPointerDown: (e: { pointerType: string }) => {
lastType.current = e.pointerType;
},
onClick: (e: { detail: number }) => activate(i, e.detail),
onFocus: (e: { currentTarget: HTMLElement }) => {
setRoving(i);
const inp = input.current;
let vis = false;
try {
vis = e.currentTarget.matches(":focus-visible");
} catch {
vis = false;
}
inp.fi = i;
inp.fx = vis ? PAD_X + i * (cfg.current.base + cfg.current.gap) + cfg.current.base / 2 : null;
wake.current();
},
onBlur: () => {
input.current.fx = null;
wake.current();
},
};
const face = (
<span
ref={(el) => {
tileRefs.current[i] = el;
}}
className="mnd-tile absolute inset-0 block"
style={{ borderRadius: "27%" }}
>
<Tile icon={icon} />
</span>
);
return (
<li
key={i}
ref={(el) => {
liRefs.current[i] = el;
}}
className="absolute"
style={{ left, bottom: PAD_B, width: base, height: base, fontSize: base }}
>
<div
ref={(el) => {
liftRefs.current[i] = el;
}}
className="h-full w-full"
>
{href ? (
<a href={href} {...ctlProps}>
{face}
</a>
) : (
<button type="button" {...ctlProps}>
{face}
</button>
)}
</div>
<span
aria-hidden
className="pointer-events-none absolute left-1/2 block rounded-full"
style={{
top: "100%",
marginTop: 7,
width: 4,
height: 4,
marginLeft: -2,
background: "#fff",
boxShadow: "0 0 6px rgba(255,255,255,0.7)",
opacity: isActive ? 0.9 : 0,
transform: isActive ? "scale(1)" : "scale(0.3)",
transition: "opacity 200ms ease, transform 260ms cubic-bezier(.2,.9,.3,1.4)",
}}
/>
</li>
);
})}
</ul>
<div
ref={tipRef}
aria-hidden
className="pointer-events-none absolute whitespace-nowrap"
style={{
left: 0,
bottom: PAD_B + base + 10,
opacity: 0,
padding: "6px 11px",
borderRadius: 10,
fontSize: 12,
lineHeight: 1,
fontWeight: 500,
letterSpacing: "0.01em",
color: "#f4f5f7",
background: "rgba(12,13,16,0.92)",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.12), 0 8px 20px -6px rgba(0,0,0,0.6)",
transform: "translate3d(-50%,6px,0)",
}}
/>
</div>
</nav>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
One document-level pointer listener maps the cursor into the dock’s own coordinates (corrected for any parent scale), and a single requestAnimationFrame loop does the rest. Each tile is sized from its distance to the pointer through a smoothstep falloff (120px of reach by default, 44px at rest, 72px under the cursor), and each size chases its target on its own damped spring, so neighbours ease into the swell instead of flipping. The loop also lays the row out: tiles push their neighbours aside and the glass bar widens symmetrically, as the real dock does, while distances are measured against the resting layout so nothing feeds back on itself. A tooltip glides from tile to tile rather than popping, a soft sheen on the bar edge follows the cursor, and clicking throws the tile into a ballistic bounce that hops two or three times. The current tile carries a small dot. Tiles are real buttons (or links when hrefs are given); the five default tiles are original inline SVG glyphs, and any string or React node can stand in for an icon. The pointer is tracked on the document, so the dock does not block clicks on the page around it, and the loop sleeps when everything is at rest. Magnified tiles rise above the bar, so leave about maxSize of free space over the dock.
Curator’s note
Magnetic Nav Dock, rebuilt in October. The first version drove icon sizes through framer-motion values; this one is a single hand-written spring loop that lays out the whole dock, so it needs nothing but React.

