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.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 { cn } from "@/lib/utils";11 12export type MagneticDockItem = {13 id: string;14 label: string;15 /** Lucide / SVG icon node */16 icon?: React.ReactNode;17 /** Square app-icon image (avoid full-page screenshots) */18 image?: string;19 /** Accent for lettermark fallback */20 color?: string;21 href?: string;22 onSelect?: () => void;23};24 25export type MagneticDockProps = {26 items: MagneticDockItem[];27 className?: string;28 /** Kept for API compat; lift/scale now comes from `.t-avatar` falloff. */29 maxSize?: number;30 /** Resting icon size (px) */31 size?: number;32 /** Kept for API compat; unused with avatar-group falloff. */33 influence?: number;34 showLabel?: boolean;35};36 37function useFinePointer() {38 const [fine, setFine] = useState(false);39 useEffect(() => {40 const mq = window.matchMedia("(hover: hover) and (pointer: fine)");41 const sync = () => setFine(mq.matches);42 sync();43 mq.addEventListener("change", sync);44 return () => mq.removeEventListener("change", sync);45 }, []);46 return fine;47}48 49function usePrefersReducedMotion() {50 const [reduce, setReduce] = useState(false);51 useEffect(() => {52 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");53 const sync = () => setReduce(mq.matches);54 sync();55 mq.addEventListener("change", sync);56 return () => mq.removeEventListener("change", sync);57 }, []);58 return reduce;59}60 61function DockItem({62 item,63 buttonId,64 tipId,65 base,66 showLabel,67 index,68 onHoverEnter,69}: {70 item: MagneticDockItem;71 buttonId: string;72 tipId: string;73 base: number;74 showLabel: boolean;75 index: number;76 onHoverEnter: (index: number) => void;77}) {78 const radius = Math.max(11, Math.round(base * 0.24));79 80 const handleActivate = () => {81 item.onSelect?.();82 if (item.href) window.open(item.href, "_blank", "noopener,noreferrer");83 };84 85 const face = item.image ? (86 // eslint-disable-next-line @next/next/no-img-element87 <img88 src={item.image}89 alt=""90 draggable={false}91 className="h-full w-full object-cover"92 />93 ) : item.icon ? (94 <span className="flex h-[52%] w-[52%] items-center justify-center text-(--color-ink) [&_svg]:h-full [&_svg]:w-full [&_svg]:stroke-[1.5]">95 {item.icon}96 </span>97 ) : (98 <span99 className="font-medium tracking-tight text-white select-none"100 style={{ fontSize: Math.max(12, base * 0.36) }}101 >102 {item.label.slice(0, 1).toUpperCase()}103 </span>104 );105 106 return (107 <span108 className="t-tt-wrap t-avatar relative flex flex-col items-center justify-end"109 style={{110 width: base,111 height: base,112 // Grow upward like macOS Dock — not from center113 transformOrigin: "bottom center",114 }}115 onMouseEnter={() => onHoverEnter(index)}116 >117 <button118 id={buttonId}119 type="button"120 aria-label={item.label}121 aria-describedby={showLabel ? tipId : undefined}122 onClick={handleActivate}123 onFocus={() => onHoverEnter(index)}124 style={{125 width: base,126 height: base,127 borderRadius: radius,128 background:129 !item.image && !item.icon130 ? (item.color ?? "oklch(0.45 0.08 250)")131 : undefined,132 }}133 className={cn(134 "t-tt-trigger relative flex items-center justify-center overflow-hidden",135 "outline-none",136 // Press feedback via filter — avoid nested scale fighting .t-avatar137 "transition-[filter,box-shadow] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",138 "active:brightness-[0.92]",139 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",140 !item.image &&141 item.icon &&142 "bg-(--color-paper-2) text-(--color-ink)",143 "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.06)]",144 "outline outline-1 outline-black/6 dark:outline-white/10",145 "dark:shadow-[0_4px_16px_rgba(0,0,0,0.35)]",146 )}147 >148 {face}149 </button>150 151 {showLabel ? (152 <span className="t-tt" id={tipId} role="tooltip">153 {item.label}154 </span>155 ) : null}156 </span>157 );158}159 160export function MagneticDock({161 items,162 className,163 maxSize: _maxSize = 60,164 size = 44,165 influence: _influence = 88,166 showLabel = true,167}: MagneticDockProps) {168 void _maxSize;169 void _influence;170 const finePointer = useFinePointer();171 const reduceMotion = usePrefersReducedMotion();172 const rootRef = useRef<HTMLElement>(null);173 const [hoverId, setHoverId] = useState<string | null>(null);174 const listId = useId();175 const itemIds = items.map((i) => `${listId}-${i.id}`);176 177 const setShifts = useCallback(178 (activeIdx: number | null, phase: "in" | "out") => {179 if (!rootRef.current) return;180 // Magnification is pointer/hover theatre — skip on touch + reduced motion181 if (!finePointer || reduceMotion) {182 rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el) => {183 el.style.setProperty("--shift", "0px");184 el.style.setProperty("--scale-active", "1");185 });186 return;187 }188 189 const cs = getComputedStyle(rootRef.current);190 const num = (name: string, fb: number) => {191 const v = parseFloat(cs.getPropertyValue(name));192 return Number.isFinite(v) ? v : fb;193 };194 const ease = (name: string, fb: string) =>195 cs.getPropertyValue(name).trim() || fb;196 197 const lift = num("--avatar-lift", -12);198 const falloff = num("--avatar-falloff", 0.5);199 const peak = num("--avatar-scale", 1.32);200 const tf =201 phase === "out"202 ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")203 : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");204 205 rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el, i) => {206 // Timing function BEFORE variable writes (transitions.dev contract)207 el.style.transitionTimingFunction = tf;208 if (activeIdx == null) {209 el.style.setProperty("--shift", "0px");210 el.style.setProperty("--scale-active", "1");211 return;212 }213 const d = Math.abs(i - activeIdx);214 const influence = Math.pow(falloff, d);215 el.style.setProperty(216 "--shift",217 (lift * influence).toFixed(3) + "px",218 );219 // Neighbors scale too — Apple dock comb, not only the active tile220 el.style.setProperty(221 "--scale-active",222 (1 + (peak - 1) * influence).toFixed(4),223 );224 });225 },226 [finePointer, reduceMotion],227 );228 229 const focusByIndex = (index: number) => {230 const clamped = Math.min(Math.max(index, 0), items.length - 1);231 const el = document.getElementById(itemIds[clamped]);232 el?.focus();233 setHoverId(items[clamped].id);234 // Keyboard: snap magnification with no spring (Emil — don't animate keys)235 const avatars = rootRef.current?.querySelectorAll<HTMLElement>(".t-avatar");236 avatars?.forEach((node) => {237 node.style.transitionDuration = "0ms";238 });239 setShifts(clamped, "in");240 requestAnimationFrame(() => {241 avatars?.forEach((node) => {242 node.style.transitionDuration = "";243 });244 });245 };246 247 const onKeyDown = (e: React.KeyboardEvent) => {248 const idx = items.findIndex((i) => i.id === hoverId);249 if (e.key === "ArrowRight") {250 e.preventDefault();251 focusByIndex((idx < 0 ? -1 : idx) + 1);252 } else if (e.key === "ArrowLeft") {253 e.preventDefault();254 focusByIndex(idx < 0 ? 0 : idx - 1);255 } else if (e.key === "Home") {256 e.preventDefault();257 focusByIndex(0);258 } else if (e.key === "End") {259 e.preventDefault();260 focusByIndex(items.length - 1);261 }262 };263 264 return (265 <div266 className={cn("flex w-full justify-center px-2", className)}267 onMouseLeave={() => {268 setHoverId(null);269 setShifts(null, "out");270 }}271 >272 <nav273 ref={rootRef}274 aria-label="Application dock"275 onKeyDown={onKeyDown}276 onBlur={(e) => {277 if (!e.currentTarget.contains(e.relatedTarget as Node)) {278 setHoverId(null);279 setShifts(null, "out");280 }281 }}282 className={cn(283 "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",284 "border border-(--color-rule) bg-(--color-paper)/85",285 "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)]",286 "backdrop-blur-2xl backdrop-saturate-150",287 "dark:border-white/10 dark:bg-(--color-paper)/80",288 "dark:shadow-[0_1px_0_rgba(255,255,255,0.08)_inset,0_10px_32px_rgba(0,0,0,0.45)]",289 "overflow-visible",290 )}291 style={292 {293 // Dock personality on top of transitions.dev avatar tokens294 "--avatar-lift": "-12px",295 "--avatar-scale": "1.34",296 "--avatar-falloff": "0.52",297 "--avatar-dur": "200ms",298 "--avatar-ease-in": "cubic-bezier(0.22, 1, 0.36, 1)",299 "--avatar-ease-out": "cubic-bezier(0.34, 3.85, 0.64, 1)",300 // Room for lift + tip above icons301 minHeight: size + 36,302 paddingTop: 18,303 } as React.CSSProperties304 }305 >306 {items.map((item, index) => (307 <DockItem308 key={item.id}309 item={item}310 buttonId={`${listId}-${item.id}`}311 tipId={`${listId}-tip-${item.id}`}312 base={size}313 showLabel={showLabel}314 index={index}315 onHoverEnter={(i) => {316 setHoverId(items[i].id);317 setShifts(i, "in");318 }}319 />320 ))}321 </nav>322 </div>323 );324}
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: "craft", label: "Craft", 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.