Overview
Move across the dock and nearby icons grow with a cosine distance falloff. Neighboring icons move aside and the frosted-glass shell widens with them (by real pixels, so its rounded corners never stretch). Each icon casts a soft, blurred contact shadow that deepens as it lifts, labels rise out of a light blur with a small spring, and activating an icon plays a macOS-style launch bounce. Springs retarget from their current position, so changing direction feels continuous.
Keyboard navigation stays at the resting size. Labels appear immediately on focus, arrow keys move between items, Home and End jump to the edges, and Escape dismisses the label. Touch devices show persistent names beneath each icon and can swipe an overflowing dock horizontally.
Installation
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/magnetic-dock.jsonFor manual installation, install motion and copy the source. Icons are supplied by your app; the demo uses lucide-react. Styles are included with neutral color fallbacks. No shared CSS snippets or class-merging utility are required.
"use client"; import { useEffect, useId, useRef, useState } from "react";import type { CSSProperties, KeyboardEvent, PointerEvent, ReactNode,} from "react";import { motion, useAnimate, useMotionValue, useReducedMotion, useSpring, useTransform,} from "motion/react";import type { MotionValue } from "motion/react"; export type MagneticDockItem = { id: string; label: string; icon?: ReactNode; image?: string; color?: string; href?: string; target?: "_blank" | "_self"; onSelect?: () => void;};export type MagneticDockProps = { items: MagneticDockItem[]; size?: number; maxSize?: number; influence?: number; showLabel?: boolean; activeId?: string; label?: string; className?: string;}; type DockGeometry = { shifts: number[]; scales: number[]; lifts: number[]; /** Pixels the shell grows on each side, so its corners never distort. */ shellExtra: number;};const GAP = 10;const SPRING = { stiffness: 500, damping: 36, mass: 0.6 };const bounded = (value: number, min: number, max: number, fallback: number) => Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; /** Transform-only magnification: stable hit areas, springs, and space for neighbors. */export function MagneticDock({ items, size = 44, maxSize = 64, influence = 120, showLabel = true, activeId, label = "Application dock", className = "",}: MagneticDockProps) { const base = bounded(size, 44, 96, 44); const peak = bounded(maxSize, base, base * 1.8, Math.max(base, 64)); const radius = bounded(influence, base, 400, 120); const reduceMotion = useReducedMotion(); const pointer = useMotionValue(-Infinity); const navRef = useRef<HTMLElement>(null); const keyboardRef = useRef(false); const [keyboard, setKeyboard] = useState(false); const [rtl, setRtl] = useState(false); const [focused, setFocused] = useState<number | null>(null); const [hovered, setHovered] = useState<number | null>(null); const [tip, setTip] = useState<number | null>(null); const tipRef = useRef<number | null>(null); const tipTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const id = useId(); const geometry = useTransform(pointer, (position): DockGeometry => { const growth = items.map((_, index) => { const distance = Math.abs( position - (12 + base / 2 + index * (base + GAP)), ); const weight = reduceMotion || keyboard || distance >= radius ? 0 : (Math.cos((distance / radius) * Math.PI) + 1) / 2; return (peak - base) * weight; }); const total = growth.reduce((sum, value) => sum + value, 0); let preceding = 0; const shifts = growth.map((value) => { const shift = preceding + value / 2 - total / 2; preceding += value; return rtl ? -shift : shift; }); return { shifts, scales: growth.map((value) => 1 + value / base), lifts: growth.map((value) => -value * 0.4), shellExtra: total / 2, }; }); const shellTarget = useTransform(geometry, (value) => value.shellExtra); const shellSpring = useSpring(shellTarget, SPRING); const shellInset = useTransform(shellSpring, (value) => -value); function clearTimer() { if (tipTimer.current) clearTimeout(tipTimer.current); tipTimer.current = null; } function revealTip(index: number, immediately = false) { clearTimer(); if (immediately || tipRef.current !== null) { tipRef.current = index; setTip(index); } else { tipTimer.current = setTimeout(() => { tipRef.current = index; setTip(index); }, 160); } } function clearHover() { pointer.set(-Infinity); setHovered(null); clearTimer(); tipRef.current = keyboardRef.current ? focused : null; setTip(tipRef.current); } useEffect( () => () => { if (tipTimer.current) clearTimeout(tipTimer.current); }, [], ); useEffect(() => { if (keyboard || reduceMotion) { pointer.set(-Infinity); shellSpring.jump(0); } }, [keyboard, reduceMotion, pointer, shellSpring]); useEffect(() => { setFocused(null); setHovered(null); setTip(null); tipRef.current = null; pointer.set(-Infinity); if (tipTimer.current) clearTimeout(tipTimer.current); }, [items.length, pointer]); function onKeyDown(event: KeyboardEvent<HTMLElement>) { keyboardRef.current = true; setKeyboard(true); pointer.set(-Infinity); const target = (event.target as HTMLElement).closest<HTMLElement>( "[data-dock-index]", ); if (!target || !items.length) return; const index = Number(target.dataset.dockIndex); const rtl = getComputedStyle(event.currentTarget).direction === "rtl"; let next: number | undefined; if (event.key === "ArrowRight") next = (index + (rtl ? -1 : 1) + items.length) % items.length; if (event.key === "ArrowLeft") next = (index + (rtl ? 1 : -1) + items.length) % items.length; if (event.key === "Home") next = 0; if (event.key === "End") next = items.length - 1; if (event.key === "Escape") { clearTimer(); tipRef.current = null; setTip(null); } if (next !== undefined) { event.preventDefault(); const control = navRef.current?.querySelector<HTMLElement>( `[data-dock-index="${next}"]`, ); control?.focus({ preventScroll: true }); control?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant", }); } } if (!items.length) return null; return ( <div className={`magnetic-dock ${className}`} data-keyboard={keyboard}> <style>{styles}</style> <div className="magnetic-dock-viewport" onScroll={clearHover}> <nav ref={navRef} className="magnetic-dock-nav" aria-label={label} style={{ gap: GAP, paddingInline: 12 }} onKeyDown={onKeyDown} onPointerDown={() => { keyboardRef.current = false; setKeyboard(false); }} onPointerMove={(event) => { if ( event.pointerType !== "mouse" || reduceMotion || !window.matchMedia("(hover:hover) and (pointer:fine)").matches ) return; keyboardRef.current = false; if (keyboard) setKeyboard(false); const viewport = event.currentTarget.parentElement; if (viewport && viewport.scrollWidth > viewport.clientWidth + 1) { pointer.set(-Infinity); return; } const bounds = event.currentTarget.getBoundingClientRect(); // Offset coordinates stay fixed while the visual faces move. const isRtl = getComputedStyle(event.currentTarget).direction === "rtl"; if (isRtl !== rtl) setRtl(isRtl); pointer.set( isRtl ? bounds.right - event.clientX : event.clientX - bounds.left, ); }} onPointerLeave={clearHover} onFocusCapture={(event) => { const control = (event.target as HTMLElement).closest<HTMLElement>( "[data-dock-index]", ); if (!control) return; const index = Number(control.dataset.dockIndex); const fromKeyboard = control.matches(":focus-visible"); keyboardRef.current = fromKeyboard; setKeyboard(fromKeyboard); setFocused(index); if (fromKeyboard) revealTip(index, true); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) { setFocused(null); if (keyboardRef.current) { clearTimer(); tipRef.current = null; setTip(null); } } }} > <motion.div className="magnetic-dock-shell" aria-hidden="true" style={{ left: shellInset, right: shellInset }} /> {items.map((item, index) => ( <DockItem key={item.id} item={item} index={index} count={items.length} base={base} geometry={geometry} instant={keyboard || !!reduceMotion} reduce={!!reduceMotion} peakScale={peak / base} active={item.id === activeId} emphasized={hovered === index || (keyboard && focused === index)} tipId={`${id}-tip-${item.id}`} visibleTip={showLabel && tip === index} showLabel={showLabel} onHover={(pointerType) => { if ( pointerType !== "mouse" || !window.matchMedia("(hover:hover) and (pointer:fine)").matches ) return; setHovered(index); revealTip(index); }} /> ))} </nav> </div> </div> );} function DockItem({ item, index, count, base, geometry, instant, reduce, peakScale, active, emphasized, tipId, visibleTip, showLabel, onHover,}: { item: MagneticDockItem; index: number; count: number; base: number; geometry: MotionValue<DockGeometry>; instant: boolean; reduce: boolean; peakScale: number; active: boolean; emphasized: boolean; tipId: string; visibleTip: boolean; showLabel: boolean; onHover: (pointerType: string) => void;}) { const xTarget = useTransform(geometry, (value) => value.shifts[index] ?? 0); const yTarget = useTransform(geometry, (value) => value.lifts[index] ?? 0); const scaleTarget = useTransform( geometry, (value) => value.scales[index] ?? 1, ); const x = useSpring(xTarget, SPRING); const y = useSpring(yTarget, SPRING); const scale = useSpring(scaleTarget, SPRING); const transform = useTransform( () => `translate3d(${x.get()}px,${y.get()}px,0) scale(${scale.get()})`, ); const tipTransform = useTransform( () => `translate3d(${x.get()}px,${y.get()}px,0)`, ); // Contact shadow: grows and softens as the icon magnifies and lifts. const shadowOpacity = useTransform(scale, [1, peakScale], [0, 1]); const shadowScale = useTransform(scale, [1, peakScale], [0.6, 1]); const shadowX = useTransform(x, (v) => v); // macOS-style launch bounce on activation. const [bounceRef, animateBounce] = useAnimate<HTMLSpanElement>(); const launch = () => { item.onSelect?.(); if (reduce || !bounceRef.current) return; animateBounce( bounceRef.current, { y: [0, -base * 0.42, 0, -base * 0.16, 0] }, { duration: 0.75, times: [0, 0.3, 0.55, 0.75, 1], ease: ["easeOut", "easeIn", "easeOut", "easeIn"] }, ); }; const [failedImage, setFailedImage] = useState(false); useEffect(() => setFailedImage(false), [item.image]); useEffect(() => { if (instant) { x.jump(0); y.jump(0); scale.jump(1); } }, [instant, x, y, scale]); const image = item.image && !failedImage; const face = image ? ( // eslint-disable-next-line @next/next/no-img-element <img src={item.image} alt="" draggable={false} onError={() => setFailedImage(true)} /> ) : item.icon ? ( <span className="magnetic-dock-icon" aria-hidden="true"> {item.icon} </span> ) : ( <span className="magnetic-dock-letter" aria-hidden="true"> {item.label.trim().slice(0, 1).toUpperCase() || "?"} </span> ); const target = item.target ?? "_blank"; const props = { "data-dock-index": index, "aria-label": `${item.label}${item.href && target === "_blank" ? " (opens in new tab)" : ""}`, "aria-current": active ? ("page" as const) : undefined, className: "magnetic-dock-control", onClick: launch, onPointerEnter: (event: PointerEvent<HTMLElement>) => onHover(event.pointerType), style: { width: base, height: base, borderRadius: Math.round(base * 0.25), pointerEvents: "auto", ...(!image && !item.icon ? { background: item.color ?? "#45735d", color: "white" } : {}), } as CSSProperties, }; return ( <span className="magnetic-dock-slot" style={{ width: base, height: base, zIndex: emphasized ? 2 : 1 }} > <motion.span className="magnetic-dock-shadow" aria-hidden="true" style={{ opacity: shadowOpacity, scale: shadowScale, x: shadowX }} /> <motion.span className="magnetic-dock-face" style={{ transform }}> <span ref={bounceRef} className="magnetic-dock-bounce"> {item.href ? ( <a {...props} href={item.href} target={target} rel={target === "_blank" ? "noopener noreferrer" : undefined} > {face} {showLabel && ( <span className="magnetic-dock-touch-label" aria-hidden="true"> {item.label} </span> )} </a> ) : ( <button {...props} type="button"> {face} {showLabel && ( <span className="magnetic-dock-touch-label" aria-hidden="true"> {item.label} </span> )} </button> )} </span> </motion.span> {active && <span className="magnetic-dock-current" aria-hidden="true" />} {showLabel && ( <motion.span id={tipId} role="tooltip" aria-hidden={!visibleTip} className="magnetic-dock-tooltip" data-visible={visibleTip} data-edge={ index === 0 ? "start" : index === count - 1 ? "end" : "middle" } style={{ transform: tipTransform }} > {item.label} </motion.span> )} </span> );} const styles = `.magnetic-dock{--dock-paper:var(--color-paper,#fff);--dock-ink:var(--color-ink,#18181b);--dock-soft:var(--color-paper-2,#f4f4f5);--dock-rule:var(--color-rule,#e4e4e7);--dock-accent:var(--color-focus,#0485f7);width:100%;min-width:0;display:flex;justify-content:center;color:var(--dock-ink);container-type:inline-size;user-select:none}.magnetic-dock-viewport{max-width:100%;width:max-content;overflow-x:auto;overscroll-behavior-x:contain;padding:60px 40px 28px;scrollbar-width:thin;scrollbar-color:var(--dock-rule) transparent}.magnetic-dock-nav{position:relative;display:flex;width:max-content;align-items:center;padding-block:11px 17px;isolation:isolate}.magnetic-dock-shell{position:absolute;top:0;bottom:0;z-index:-1;border-radius:22px;background:color-mix(in oklab,var(--dock-paper) 70%,transparent);-webkit-backdrop-filter:blur(20px) saturate(1.6);backdrop-filter:blur(20px) saturate(1.6);box-shadow:inset 0 1px 0 rgba(255,255,255,.65),inset 0 0 0 1px color-mix(in oklab,var(--dock-rule) 85%,transparent),0 2px 6px rgba(0,0,0,.04),0 16px 36px -14px rgba(0,0,0,.2);pointer-events:none}.dark .magnetic-dock-shell{box-shadow:inset 0 1px 0 rgba(255,255,255,.07),inset 0 0 0 1px color-mix(in oklab,var(--dock-rule) 85%,transparent),0 16px 40px -12px rgba(0,0,0,.6)}.magnetic-dock-slot{position:relative;display:block;flex:none}.magnetic-dock-face{display:block;transform-origin:center bottom}.magnetic-dock-bounce{display:block}.magnetic-dock-shadow{position:absolute;left:8%;right:8%;bottom:-7px;height:8px;border-radius:50%;background:radial-gradient(closest-side,rgba(0,0,0,.28),transparent);filter:blur(3px);pointer-events:none}.magnetic-dock-control{position:relative;display:flex;align-items:center;justify-content:center;flex:none;overflow:hidden;background:linear-gradient(to bottom,var(--dock-paper),color-mix(in oklab,var(--dock-soft) 75%,var(--dock-paper)));color:var(--dock-ink);text-decoration:none;cursor:pointer;outline:none;scroll-margin-inline:10px;-webkit-tap-highlight-color:transparent;transition:transform 120ms cubic-bezier(.23,1,.32,1),filter 160ms ease;box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 0 0 0 1px var(--dock-rule),0 1px 2px rgba(0,0,0,.05)}.dark .magnetic-dock-control{box-shadow:inset 0 1px 0 rgba(255,255,255,.08),inset 0 0 0 1px var(--dock-rule),0 1px 2px rgba(0,0,0,.3)}.magnetic-dock-control[aria-current=page]{color:var(--dock-accent);background:linear-gradient(to bottom,color-mix(in oklab,var(--dock-accent) 6%,var(--dock-paper)),color-mix(in oklab,var(--dock-accent) 12%,var(--dock-paper)));box-shadow:inset 0 1px 0 rgba(255,255,255,.8),inset 0 0 0 1px color-mix(in oklab,var(--dock-accent) 28%,var(--dock-rule)),0 1px 2px rgba(0,0,0,.05)}.magnetic-dock-control:active{transform:scale(.94)}.magnetic-dock-control:focus-visible{outline:2px solid var(--dock-accent);outline-offset:4px}.magnetic-dock-control img{display:block;width:100%;height:100%;object-fit:cover;border-radius:inherit}.magnetic-dock-icon{display:flex;width:50%;height:50%;align-items:center;justify-content:center}.magnetic-dock-icon svg{width:100%;height:100%;stroke-width:1.6}.magnetic-dock-letter{font-size:18px;font-weight:600;line-height:1;user-select:none}.magnetic-dock-current{position:absolute;bottom:-11px;left:calc(50% - 2px);width:4px;height:4px;border-radius:50%;background:var(--dock-accent);box-shadow:0 0 6px 1px color-mix(in oklab,var(--dock-accent) 60%,transparent)}.magnetic-dock-touch-label{display:none}.magnetic-dock-tooltip{position:absolute;bottom:calc(100% + 16px);left:50%;translate:-50% 0;width:max-content;max-width:180px;display:flex;justify-content:center;pointer-events:none;color:var(--dock-paper);font-family:inherit;font-size:11.5px;font-weight:500;line-height:1.4;white-space:normal;text-align:center;overflow-wrap:anywhere;transform-origin:center bottom;opacity:0;visibility:hidden;filter:blur(4px);scale:.86;transition:opacity 160ms ease,filter 200ms ease,scale 260ms cubic-bezier(.34,1.45,.64,1),visibility 0s linear 260ms}.magnetic-dock-tooltip::before{content:"";position:absolute;inset:-6px -10px;z-index:-1;border-radius:8px;background:color-mix(in oklab,var(--dock-ink) 86%,transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);box-shadow:0 6px 16px -6px rgba(0,0,0,.35)}.magnetic-dock-tooltip[data-edge=start]{left:0;translate:0 0;transform-origin:left bottom}.magnetic-dock-tooltip[data-edge=end]{left:auto;right:0;translate:0 0;transform-origin:right bottom}.magnetic-dock-tooltip[data-visible=true]{opacity:1;visibility:visible;filter:blur(0);scale:1;transition:opacity 160ms ease,filter 200ms ease,scale 260ms cubic-bezier(.34,1.45,.64,1),visibility 0s}.magnetic-dock[data-keyboard=true] .magnetic-dock-tooltip,.magnetic-dock[data-keyboard=true] .magnetic-dock-control{transition:none}.magnetic-dock[data-keyboard=true] .magnetic-dock-control:active{transform:none}@container(max-width:420px){.magnetic-dock-viewport{padding-inline:18px}.magnetic-dock-nav{padding-bottom:40px}.magnetic-dock-touch-label{display:block;position:absolute;top:calc(100% + 13px);left:-4px;right:-4px;text-align:center;font-family:inherit;font-size:10px;font-weight:500;line-height:1.3;white-space:normal;overflow-wrap:anywhere;color:var(--dock-ink)}.magnetic-dock-tooltip{display:none}}@media(hover:none),(pointer:coarse){.magnetic-dock-nav{padding-bottom:40px}.magnetic-dock-touch-label{display:block;position:absolute;top:calc(100% + 13px);left:-4px;right:-4px;text-align:center;font-family:inherit;font-size:10px;font-weight:500;line-height:1.3;white-space:normal;overflow-wrap:anywhere;color:var(--dock-ink)}.magnetic-dock-tooltip{display:none}}@media(prefers-reduced-motion:reduce){.magnetic-dock-control{transition:none}.magnetic-dock-control:active{transform:none}.magnetic-dock-tooltip,.magnetic-dock-tooltip[data-visible=true]{filter:none;scale:1;transition:opacity 120ms ease,visibility 0s}}@media(hover:hover) and (pointer:fine){.magnetic-dock-control:hover{filter:brightness(1.03)}}`;
Usage
import { MagneticDock } from "@/components/ui/magnetic-dock";import { ShoppingBag, Sparkles, Settings } from "lucide-react"; <MagneticDock activeId="craft" size={44} maxSize={64} influence={120} items={[ { id: "store", label: "Store", icon: <ShoppingBag />, href: "https://woxly.store", }, { id: "craft", label: "Craft", icon: <Sparkles />, href: "/craft", target: "_self", }, { id: "settings", label: "Settings", icon: <Settings />, onSelect: openSettings, }, ]}/>;
Supply href for navigation or onSelect for an action. Links use native anchors so opening in a new tab, copying the link, and modifier clicks work normally. The default target is _blank, matching the project-shortcut demo; use _self for navigation within your app. Items with both properties call onSelect on an ordinary click before following the link.
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | MagneticDockItem[] | Yes | — | Unique id, label, optional icon or square image, color, href, target, and onSelect. Empty lists render nothing. |
| size | number | No | 44 | Resting size in pixels, clamped to 44–96 to preserve usable touch targets. |
| maxSize | number | No | 64 | Peak visual size under the pointer. Clamped between size and 1.8 × size. |
| influence | number | No | 120 | Pointer influence radius in pixels, clamped between size and 400. |
| showLabel | boolean | No | true | Show hover/focus tooltips and persistent names on touch devices. |
| activeId | string | No | — | Current destination. Adds aria-current, a subtle accent, and an indicator dot. |
| label | string | No | Application dock | Accessible name for this navigation region. Use distinct names for multiple docks. |
| className | string | No | — | Additional classes for the outer wrapper. |
Interaction details
- Hover magnification runs only for a mouse on a device with fine-pointer hover support. Touch targets stay still.
- Transform springs move the faces and background while the underlying layout stays fixed. Neighboring faces keep a consistent gap.
- The first hover label waits 160 ms. Moving to another icon while a label is visible skips that delay.
- Each link or button stays in the normal Tab order. Arrow keys wrap through the dock; in right-to-left layouts their direction follows the visual order.
- Reduced motion disables magnification and movement. Labels and focus indicators remain available.
- Broken images fall back to the supplied icon or the first letter of the label. Provide meaningful labels and square images.
- The dock scrolls within its container when it cannot fit. Focused offscreen items scroll into view without animation.