Overview
A usable macOS-style dock: icons expand their layout slots (no overlap), labels appear above the active item, and magnification only runs on fine pointers. Touch gets a static dock with press feedback.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/magnetic-dock.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, {4 useCallback,5 useEffect,6 useId,7 useRef,8 useState,9} from "react";10import {11 motion,12 useMotionValue,13 useSpring,14 useReducedMotion,15 AnimatePresence,16} from "motion/react";17import { cn } from "@/lib/utils";18 19export type MagneticDockItem = {20 id: string;21 label: string;22 /** Lucide / SVG icon node */23 icon?: React.ReactNode;24 /** Square app-icon image (avoid full-page screenshots) */25 image?: string;26 /** Accent for lettermark fallback */27 color?: string;28 href?: string;29 onSelect?: () => void;30};31 32export type MagneticDockProps = {33 items: MagneticDockItem[];34 className?: string;35 /** Peak icon size under the cursor (px) */36 maxSize?: number;37 /** Resting icon size (px) */38 size?: number;39 /** Magnification falloff radius (px) */40 influence?: number;41 showLabel?: boolean;42};43 44function useFinePointer() {45 const [fine, setFine] = useState(false);46 useEffect(() => {47 const mq = window.matchMedia("(hover: hover) and (pointer: fine)");48 const sync = () => setFine(mq.matches);49 sync();50 mq.addEventListener("change", sync);51 return () => mq.removeEventListener("change", sync);52 }, []);53 return fine;54}55 56/** Cosine falloff — macOS-style dock curve */57function magnify(58 distance: number,59 influence: number,60 base: number,61 max: number,62) {63 if (distance >= influence) return base;64 const t = distance / influence;65 return base + (max - base) * Math.cos((t * Math.PI) / 2);66}67 68function DockItem({69 item,70 buttonId,71 mouseX,72 base,73 max,74 influence,75 reduce,76 finePointer,77 labeled,78 emphasized,79 onHover,80}: {81 item: MagneticDockItem;82 buttonId: string;83 mouseX: ReturnType<typeof useMotionValue<number>>;84 base: number;85 max: number;86 influence: number;87 reduce: boolean | null;88 finePointer: boolean;89 labeled: boolean;90 /** Keyboard / hover emphasis when pointer isn't driving the curve */91 emphasized: boolean;92 onHover: (id: string | null) => void;93}) {94 const ref = useRef<HTMLButtonElement>(null);95 const sizeMv = useMotionValue(base);96 const size = useSpring(sizeMv, {97 stiffness: 420,98 damping: 32,99 mass: 0.32,100 });101 const [px, setPx] = useState(base);102 103 const sync = useCallback(() => {104 if (reduce || !finePointer || !ref.current) {105 sizeMv.set(base);106 return;107 }108 const mx = mouseX.get();109 if (mx < 0) {110 // Arrows / focus: gentle lift on the active item, rest stay put111 sizeMv.set(emphasized ? base + (max - base) * 0.55 : base);112 return;113 }114 const rect = ref.current.getBoundingClientRect();115 const center = rect.left + rect.width / 2;116 sizeMv.set(magnify(Math.abs(mx - center), influence, base, max));117 }, [118 base,119 emphasized,120 finePointer,121 influence,122 max,123 mouseX,124 reduce,125 sizeMv,126 ]);127 128 useEffect(() => mouseX.on("change", sync), [mouseX, sync]);129 useEffect(() => {130 sync();131 }, [sync]);132 useEffect(() => {133 return size.on("change", (v) => setPx(Math.round(v * 100) / 100));134 }, [size]);135 136 useEffect(() => {137 if (reduce || !finePointer) {138 sizeMv.set(base);139 setPx(base);140 }141 }, [base, finePointer, reduce, sizeMv]);142 143 const radius = Math.max(11, Math.round(px * 0.24));144 145 const handleActivate = () => {146 item.onSelect?.();147 if (item.href) window.open(item.href, "_blank", "noopener,noreferrer");148 };149 150 const face = item.image ? (151 // eslint-disable-next-line @next/next/no-img-element152 <img153 src={item.image}154 alt=""155 draggable={false}156 className="h-full w-full object-cover"157 />158 ) : item.icon ? (159 <span className="flex h-[52%] w-[52%] items-center justify-center text-(--color-ink) [&_svg]:h-full [&_svg]:w-full [&_svg]:stroke-[1.5]">160 {item.icon}161 </span>162 ) : (163 <span164 className="font-medium tracking-tight text-white select-none"165 style={{ fontSize: Math.max(12, px * 0.36) }}166 >167 {item.label.slice(0, 1).toUpperCase()}168 </span>169 );170 171 return (172 <div173 className="relative flex flex-col items-center justify-end"174 // Slot height = peak only — nav padding is symmetric; don't double-reserve175 style={{ width: px, height: max }}176 >177 <AnimatePresence initial={false}>178 {labeled ? (179 <motion.span180 key="tip"181 initial={182 reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.97 }183 }184 animate={{ opacity: 1, y: 0, scale: 1 }}185 exit={186 reduce187 ? { opacity: 0, transition: { duration: 0.1 } }188 : {189 opacity: 0,190 y: 3,191 scale: 0.97,192 transition: { duration: 0.12 },193 }194 }195 transition={196 reduce197 ? { duration: 0.1 }198 : { type: "spring", bounce: 0, duration: 0.25 }199 }200 className={cn(201 "pointer-events-none absolute bottom-full z-10 mb-2 whitespace-nowrap",202 "rounded-sm border border-(--color-rule) bg-(--color-paper)/95 px-2 py-1",203 "text-[11px] font-medium text-(--color-ink)",204 "shadow-[0_4px_14px_rgba(0,0,0,0.08)] backdrop-blur-md",205 )}206 >207 {item.label}208 </motion.span>209 ) : null}210 </AnimatePresence>211 212 <button213 ref={ref}214 id={buttonId}215 type="button"216 aria-label={item.label}217 onClick={handleActivate}218 onFocus={() => onHover(item.id)}219 onMouseEnter={() => onHover(item.id)}220 style={{221 width: px,222 height: px,223 borderRadius: radius,224 background:225 !item.image && !item.icon226 ? (item.color ?? "oklch(0.45 0.08 250)")227 : undefined,228 }}229 className={cn(230 "relative flex items-center justify-center overflow-hidden",231 "outline-none",232 "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",233 "active:scale-[0.96]",234 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",235 !item.image &&236 item.icon &&237 "bg-(--color-paper-2) text-(--color-ink)",238 "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.06)]",239 "outline outline-1 outline-black/6 dark:outline-white/10",240 "dark:shadow-[0_4px_16px_rgba(0,0,0,0.35)]",241 )}242 >243 {face}244 </button>245 </div>246 );247}248 249export function MagneticDock({250 items,251 className,252 maxSize = 60,253 size = 42,254 influence = 88,255 showLabel = true,256}: MagneticDockProps) {257 const reduce = useReducedMotion();258 const finePointer = useFinePointer();259 const mouseX = useMotionValue(-1);260 const [hoverId, setHoverId] = useState<string | null>(null);261 const listId = useId();262 const peak = reduce || !finePointer ? size : maxSize;263 const itemIds = items.map((i) => `${listId}-${i.id}`);264 265 const focusByIndex = (index: number) => {266 const clamped = Math.min(Math.max(index, 0), items.length - 1);267 const el = document.getElementById(itemIds[clamped]);268 el?.focus();269 setHoverId(items[clamped].id);270 // Clear pointer drive so keyboard emphasis takes over271 mouseX.set(-1);272 };273 274 const onKeyDown = (e: React.KeyboardEvent) => {275 const idx = items.findIndex((i) => i.id === hoverId);276 if (e.key === "ArrowRight") {277 e.preventDefault();278 focusByIndex((idx < 0 ? -1 : idx) + 1);279 } else if (e.key === "ArrowLeft") {280 e.preventDefault();281 focusByIndex(idx < 0 ? 0 : idx - 1);282 } else if (e.key === "Home") {283 e.preventDefault();284 focusByIndex(0);285 } else if (e.key === "End") {286 e.preventDefault();287 focusByIndex(items.length - 1);288 }289 };290 291 return (292 <div293 className={cn("flex w-full justify-center px-2", className)}294 onMouseLeave={() => {295 mouseX.set(-1);296 setHoverId(null);297 }}298 >299 <nav300 aria-label="Application dock"301 onMouseMove={(e) => {302 if (finePointer) mouseX.set(e.clientX);303 }}304 onKeyDown={onKeyDown}305 onBlur={(e) => {306 if (!e.currentTarget.contains(e.relatedTarget as Node)) {307 setHoverId(null);308 mouseX.set(-1);309 }310 }}311 className={cn(312 // Symmetric pad + clearer item gap — magnification room lives in item slots313 "flex items-end gap-2.5 rounded-[20px] px-3.5 py-2.5 sm:gap-3 sm:px-4 sm:py-3",314 "border border-(--color-rule) bg-(--color-paper)/85",315 "shadow-[0_1px_0_rgba(255,255,255,0.5)_inset,0_10px_32px_rgba(0,0,0,0.08),0_2px_6px_rgba(0,0,0,0.04)]",316 "backdrop-blur-2xl backdrop-saturate-150",317 "dark:border-white/10 dark:bg-(--color-paper)/80",318 "dark:shadow-[0_1px_0_rgba(255,255,255,0.08)_inset,0_10px_32px_rgba(0,0,0,0.45)]",319 )}320 style={{321 // Room for the peak icon only — labels float above via absolute322 minHeight: peak + 20,323 }}324 >325 {items.map((item) => (326 <DockItem327 key={item.id}328 item={item}329 buttonId={`${listId}-${item.id}`}330 mouseX={mouseX}331 base={size}332 max={peak}333 influence={influence}334 reduce={reduce}335 finePointer={finePointer}336 labeled={Boolean(showLabel && hoverId === item.id)}337 emphasized={hoverId === item.id}338 onHover={setHoverId}339 />340 ))}341 </nav>342 </div>343 );344}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { MagneticDock } from "@/components/ui/magnetic-dock";2import { ShoppingBag, Sparkles } from "lucide-react";3 4<MagneticDock5 items={[6 { id: "woxly", label: "Woxly", icon: <ShoppingBag />, href: "https://woxly.store" },7 { id: "velnor", label: "Velnor", icon: <Sparkles />, onSelect: () => {} },8 ]}9/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | MagneticDockItem[] | Yes | — | Dock items — id, label, icon or square image, optional href / onSelect / color. |
| size | number | No | 44 | Resting icon size in pixels. |
| maxSize | number | No | 64 | Peak icon size under the cursor (layout expands — no overlap). |
| influence | number | No | 70 | Pixel radius of the cosine magnification falloff. |
| showLabel | boolean | No | true | Show a floating label above the hovered or focused item. |
| className | string | No | — | Optional class names on the outer wrapper. |
Best Practices
- Prefer clear icons (Lucide) or square app icons — full-page screenshots read as noise at dock size.
- Magnification is gated to
(hover: hover) and (pointer: fine)— touch stays static withactive:scale(0.96). - Layout width follows the spring size so neighbors make room instead of stacking.
- Wire
hreforonSelectso every item does something when activated.