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.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/magnetic-dock.tsx
tsx
"use client";
import React, {  useCallback,  useEffect,  useId,  useRef,  useState,} from "react";import {  motion,  useMotionValue,  useSpring,  useReducedMotion,  AnimatePresence,} from "motion/react";import { cn } from "@/lib/utils";
export type MagneticDockItem = {  id: string;  label: string;  /** Lucide / SVG icon node */  icon?: React.ReactNode;  /** Square app-icon image (avoid full-page screenshots) */  image?: string;  /** Accent for lettermark fallback */  color?: string;  href?: string;  onSelect?: () => void;};
export type MagneticDockProps = {  items: MagneticDockItem[];  className?: string;  /** Peak icon size under the cursor (px) */  maxSize?: number;  /** Resting icon size (px) */  size?: number;  /** Magnification falloff radius (px) */  influence?: number;  showLabel?: boolean;};
function useFinePointer() {  const [fine, setFine] = useState(false);  useEffect(() => {    const mq = window.matchMedia("(hover: hover) and (pointer: fine)");    const sync = () => setFine(mq.matches);    sync();    mq.addEventListener("change", sync);    return () => mq.removeEventListener("change", sync);  }, []);  return fine;}
/** Cosine falloff — macOS-style dock curve */function magnify(  distance: number,  influence: number,  base: number,  max: number,) {  if (distance >= influence) return base;  const t = distance / influence;  return base + (max - base) * Math.cos((t * Math.PI) / 2);}
function DockItem({  item,  buttonId,  mouseX,  base,  max,  influence,  reduce,  finePointer,  labeled,  emphasized,  onHover,}: {  item: MagneticDockItem;  buttonId: string;  mouseX: ReturnType<typeof useMotionValue<number>>;  base: number;  max: number;  influence: number;  reduce: boolean | null;  finePointer: boolean;  labeled: boolean;  /** Keyboard / hover emphasis when pointer isn't driving the curve */  emphasized: boolean;  onHover: (id: string | null) => void;}) {  const ref = useRef<HTMLButtonElement>(null);  const sizeMv = useMotionValue(base);  const size = useSpring(sizeMv, {    stiffness: 420,    damping: 32,    mass: 0.32,  });  const [px, setPx] = useState(base);
  const sync = useCallback(() => {    if (reduce || !finePointer || !ref.current) {      sizeMv.set(base);      return;    }    const mx = mouseX.get();    if (mx < 0) {      // Arrows / focus: gentle lift on the active item, rest stay put      sizeMv.set(emphasized ? base + (max - base) * 0.55 : base);      return;    }    const rect = ref.current.getBoundingClientRect();    const center = rect.left + rect.width / 2;    sizeMv.set(magnify(Math.abs(mx - center), influence, base, max));  }, [    base,    emphasized,    finePointer,    influence,    max,    mouseX,    reduce,    sizeMv,  ]);
  useEffect(() => mouseX.on("change", sync), [mouseX, sync]);  useEffect(() => {    sync();  }, [sync]);  useEffect(() => {    return size.on("change", (v) => setPx(Math.round(v * 100) / 100));  }, [size]);
  useEffect(() => {    if (reduce || !finePointer) {      sizeMv.set(base);      setPx(base);    }  }, [base, finePointer, reduce, sizeMv]);
  const radius = Math.max(11, Math.round(px * 0.24));
  const handleActivate = () => {    item.onSelect?.();    if (item.href) window.open(item.href, "_blank", "noopener,noreferrer");  };
  const face = item.image ? (    // eslint-disable-next-line @next/next/no-img-element    <img      src={item.image}      alt=""      draggable={false}      className="h-full w-full object-cover"    />  ) : item.icon ? (    <span className="flex h-[52%] w-[52%] items-center justify-center text-(--color-ink) [&_svg]:h-full [&_svg]:w-full [&_svg]:stroke-[1.5]">      {item.icon}    </span>  ) : (    <span      className="font-medium tracking-tight text-white select-none"      style={{ fontSize: Math.max(12, px * 0.36) }}    >      {item.label.slice(0, 1).toUpperCase()}    </span>  );
  return (    <div      className="relative flex flex-col items-center justify-end"      // Slot height = peak only — nav padding is symmetric; don't double-reserve      style={{ width: px, height: max }}    >      <AnimatePresence initial={false}>        {labeled ? (          <motion.span            key="tip"            initial={              reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.97 }            }            animate={{ opacity: 1, y: 0, scale: 1 }}            exit={              reduce                ? { opacity: 0, transition: { duration: 0.1 } }                : {                    opacity: 0,                    y: 3,                    scale: 0.97,                    transition: { duration: 0.12 },                  }            }            transition={              reduce                ? { duration: 0.1 }                : { type: "spring", bounce: 0, duration: 0.25 }            }            className={cn(              "pointer-events-none absolute bottom-full z-10 mb-2 whitespace-nowrap",              "rounded-sm border border-(--color-rule) bg-(--color-paper)/95 px-2 py-1",              "text-[11px] font-medium text-(--color-ink)",              "shadow-[0_4px_14px_rgba(0,0,0,0.08)] backdrop-blur-md",            )}          >            {item.label}          </motion.span>        ) : null}      </AnimatePresence>
      <button        ref={ref}        id={buttonId}        type="button"        aria-label={item.label}        onClick={handleActivate}        onFocus={() => onHover(item.id)}        onMouseEnter={() => onHover(item.id)}        style={{          width: px,          height: px,          borderRadius: radius,          background:            !item.image && !item.icon              ? (item.color ?? "oklch(0.45 0.08 250)")              : undefined,        }}        className={cn(          "relative flex items-center justify-center overflow-hidden",          "outline-none",          "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",          "active:scale-[0.96]",          "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",          !item.image &&            item.icon &&            "bg-(--color-paper-2) text-(--color-ink)",          "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.06)]",          "outline outline-1 outline-black/6 dark:outline-white/10",          "dark:shadow-[0_4px_16px_rgba(0,0,0,0.35)]",        )}      >        {face}      </button>    </div>  );}
export function MagneticDock({  items,  className,  maxSize = 60,  size = 42,  influence = 88,  showLabel = true,}: MagneticDockProps) {  const reduce = useReducedMotion();  const finePointer = useFinePointer();  const mouseX = useMotionValue(-1);  const [hoverId, setHoverId] = useState<string | null>(null);  const listId = useId();  const peak = reduce || !finePointer ? size : maxSize;  const itemIds = items.map((i) => `${listId}-${i.id}`);
  const focusByIndex = (index: number) => {    const clamped = Math.min(Math.max(index, 0), items.length - 1);    const el = document.getElementById(itemIds[clamped]);    el?.focus();    setHoverId(items[clamped].id);    // Clear pointer drive so keyboard emphasis takes over    mouseX.set(-1);  };
  const onKeyDown = (e: React.KeyboardEvent) => {    const idx = items.findIndex((i) => i.id === hoverId);    if (e.key === "ArrowRight") {      e.preventDefault();      focusByIndex((idx < 0 ? -1 : idx) + 1);    } else if (e.key === "ArrowLeft") {      e.preventDefault();      focusByIndex(idx < 0 ? 0 : idx - 1);    } else if (e.key === "Home") {      e.preventDefault();      focusByIndex(0);    } else if (e.key === "End") {      e.preventDefault();      focusByIndex(items.length - 1);    }  };
  return (    <div      className={cn("flex w-full justify-center px-2", className)}      onMouseLeave={() => {        mouseX.set(-1);        setHoverId(null);      }}    >      <nav        aria-label="Application dock"        onMouseMove={(e) => {          if (finePointer) mouseX.set(e.clientX);        }}        onKeyDown={onKeyDown}        onBlur={(e) => {          if (!e.currentTarget.contains(e.relatedTarget as Node)) {            setHoverId(null);            mouseX.set(-1);          }        }}        className={cn(          // Symmetric pad + clearer item gap — magnification room lives in item slots          "flex items-end gap-2.5 rounded-[20px] px-3.5 py-2.5 sm:gap-3 sm:px-4 sm:py-3",          "border border-(--color-rule) bg-(--color-paper)/85",          "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)]",          "backdrop-blur-2xl backdrop-saturate-150",          "dark:border-white/10 dark:bg-(--color-paper)/80",          "dark:shadow-[0_1px_0_rgba(255,255,255,0.08)_inset,0_10px_32px_rgba(0,0,0,0.45)]",        )}        style={{          // Room for the peak icon only — labels float above via absolute          minHeight: peak + 20,        }}      >        {items.map((item) => (          <DockItem            key={item.id}            item={item}            buttonId={`${listId}-${item.id}`}            mouseX={mouseX}            base={size}            max={peak}            influence={influence}            reduce={reduce}            finePointer={finePointer}            labeled={Boolean(showLabel && hoverId === item.id)}            emphasized={hoverId === item.id}            onHover={setHoverId}          />        ))}      </nav>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { MagneticDock } from "@/components/ui/magnetic-dock";import { ShoppingBag, Sparkles } from "lucide-react";
<MagneticDock  items={[    { id: "woxly", label: "Woxly", icon: <ShoppingBag />, href: "https://woxly.store" },    { id: "velnor", label: "Velnor", icon: <Sparkles />, onSelect: () => {} },  ]}/>

Props

PropTypeRequiredDefaultDescription
itemsMagneticDockItem[]YesDock items — id, label, icon or square image, optional href / onSelect / color.
sizenumberNo44Resting icon size in pixels.
maxSizenumberNo64Peak icon size under the cursor (layout expands — no overlap).
influencenumberNo70Pixel radius of the cosine magnification falloff.
showLabelbooleanNotrueShow a floating label above the hovered or focused item.
classNamestringNoOptional class names on the outer wrapper.

Best Practices

  1. Prefer clear icons (Lucide) or square app icons — full-page screenshots read as noise at dock size.
  2. Magnification is gated to (hover: hover) and (pointer: fine) — touch stays static with active:scale(0.96).
  3. Layout width follows the spring size so neighbors make room instead of stacking.
  4. Wire href or onSelect so every item does something when activated.