Overview

macOS-style dock built on transitions.dev avatar group hover (t-avatar). Hover lifts the active icon and neighbors with power-falloff scale; leave uses a bouncy ease-out. Magnification is fine-pointer only. Keyboard focus snaps without spring. Icons grow from bottom center.


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 { 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;  /** Kept for API compat; lift/scale now comes from `.t-avatar` falloff. */  maxSize?: number;  /** Resting icon size (px) */  size?: number;  /** Kept for API compat; unused with avatar-group falloff. */  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;}
function usePrefersReducedMotion() {  const [reduce, setReduce] = useState(false);  useEffect(() => {    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");    const sync = () => setReduce(mq.matches);    sync();    mq.addEventListener("change", sync);    return () => mq.removeEventListener("change", sync);  }, []);  return reduce;}
function DockItem({  item,  buttonId,  tipId,  base,  showLabel,  index,  onHoverEnter,}: {  item: MagneticDockItem;  buttonId: string;  tipId: string;  base: number;  showLabel: boolean;  index: number;  onHoverEnter: (index: number) => void;}) {  const radius = Math.max(11, Math.round(base * 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, base * 0.36) }}    >      {item.label.slice(0, 1).toUpperCase()}    </span>  );
  return (    <span      className="t-tt-wrap t-avatar relative flex flex-col items-center justify-end"      style={{        width: base,        height: base,        // Grow upward like macOS Dock — not from center        transformOrigin: "bottom center",      }}      onMouseEnter={() => onHoverEnter(index)}    >      <button        id={buttonId}        type="button"        aria-label={item.label}        aria-describedby={showLabel ? tipId : undefined}        onClick={handleActivate}        onFocus={() => onHoverEnter(index)}        style={{          width: base,          height: base,          borderRadius: radius,          background:            !item.image && !item.icon              ? (item.color ?? "oklch(0.45 0.08 250)")              : undefined,        }}        className={cn(          "t-tt-trigger relative flex items-center justify-center overflow-hidden",          "outline-none",          // Press feedback via filter — avoid nested scale fighting .t-avatar          "transition-[filter,box-shadow] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",          "active:brightness-[0.92]",          "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>
      {showLabel ? (        <span className="t-tt" id={tipId} role="tooltip">          {item.label}        </span>      ) : null}    </span>  );}
export function MagneticDock({  items,  className,  maxSize: _maxSize = 60,  size = 44,  influence: _influence = 88,  showLabel = true,}: MagneticDockProps) {  void _maxSize;  void _influence;  const finePointer = useFinePointer();  const reduceMotion = usePrefersReducedMotion();  const rootRef = useRef<HTMLElement>(null);  const [hoverId, setHoverId] = useState<string | null>(null);  const listId = useId();  const itemIds = items.map((i) => `${listId}-${i.id}`);
  const setShifts = useCallback(    (activeIdx: number | null, phase: "in" | "out") => {      if (!rootRef.current) return;      // Magnification is pointer/hover theatre — skip on touch + reduced motion      if (!finePointer || reduceMotion) {        rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el) => {          el.style.setProperty("--shift", "0px");          el.style.setProperty("--scale-active", "1");        });        return;      }
      const cs = getComputedStyle(rootRef.current);      const num = (name: string, fb: number) => {        const v = parseFloat(cs.getPropertyValue(name));        return Number.isFinite(v) ? v : fb;      };      const ease = (name: string, fb: string) =>        cs.getPropertyValue(name).trim() || fb;
      const lift = num("--avatar-lift", -12);      const falloff = num("--avatar-falloff", 0.5);      const peak = num("--avatar-scale", 1.32);      const tf =        phase === "out"          ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")          : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");
      rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el, i) => {        // Timing function BEFORE variable writes (transitions.dev contract)        el.style.transitionTimingFunction = tf;        if (activeIdx == null) {          el.style.setProperty("--shift", "0px");          el.style.setProperty("--scale-active", "1");          return;        }        const d = Math.abs(i - activeIdx);        const influence = Math.pow(falloff, d);        el.style.setProperty(          "--shift",          (lift * influence).toFixed(3) + "px",        );        // Neighbors scale too — Apple dock comb, not only the active tile        el.style.setProperty(          "--scale-active",          (1 + (peak - 1) * influence).toFixed(4),        );      });    },    [finePointer, reduceMotion],  );
  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);    // Keyboard: snap magnification with no spring (Emil — don't animate keys)    const avatars = rootRef.current?.querySelectorAll<HTMLElement>(".t-avatar");    avatars?.forEach((node) => {      node.style.transitionDuration = "0ms";    });    setShifts(clamped, "in");    requestAnimationFrame(() => {      avatars?.forEach((node) => {        node.style.transitionDuration = "";      });    });  };
  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={() => {        setHoverId(null);        setShifts(null, "out");      }}    >      <nav        ref={rootRef}        aria-label="Application dock"        onKeyDown={onKeyDown}        onBlur={(e) => {          if (!e.currentTarget.contains(e.relatedTarget as Node)) {            setHoverId(null);            setShifts(null, "out");          }        }}        className={cn(          "t-avatar-group flex items-end gap-2 rounded-[22px] px-3 py-2.5 sm:gap-2.5 sm:px-3.5 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)]",          "overflow-visible",        )}        style={          {            // Dock personality on top of transitions.dev avatar tokens            "--avatar-lift": "-12px",            "--avatar-scale": "1.34",            "--avatar-falloff": "0.52",            "--avatar-dur": "200ms",            "--avatar-ease-in": "cubic-bezier(0.22, 1, 0.36, 1)",            "--avatar-ease-out": "cubic-bezier(0.34, 3.85, 0.64, 1)",            // Room for lift + tip above icons            minHeight: size + 36,            paddingTop: 18,          } as React.CSSProperties        }      >        {items.map((item, index) => (          <DockItem            key={item.id}            item={item}            buttonId={`${listId}-${item.id}`}            tipId={`${listId}-tip-${item.id}`}            base={size}            showLabel={showLabel}            index={index}            onHoverEnter={(i) => {              setHoverId(items[i].id);              setShifts(i, "in");            }}          />        ))}      </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: "craft", label: "Craft", 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.