Dock
macOS-style animated dock with Gaussian neighbor icon magnification driven by spring physics.
Installation
File Structure
Usage
import { Dock } from "@/components/unlumen-ui/dock";
const items = [
{ icon: "🌐", label: "Browser" },
{ icon: "📁", label: "Files", href: "/files" },
];
<Dock items={items} />;Custom magnification
<Dock
items={items}
magnification={3}
distance={120}
springOptions={{ stiffness: 400, damping: 25 }}
/>API Reference
Dock
itemsDockItem[]—Array of dock items to render.
magnification?number2.2Maximum scale factor applied to the icon directly under the cursor.
distance?number100Pixel radius from the cursor within which neighbor icons are magnified.
iconSize?number48Base icon size in pixels (width & height).
gap?number12Gap between icons in pixels.
alwaysShowLabels?booleanfalseWhen true, labels are always visible beneath icons instead of appearing only on hover.
springOptions?SpringOptions{ stiffness: 300, damping: 22, mass: 0.5 }Motion spring options for the scale animation.
className?string—Extra classes on the dock container.
DockItem
iconReact.ReactNode—Icon content — emoji, image, or any React node.
labelstring—Tooltip text shown above the icon on hover.
href?string—If provided, the item renders as an anchor tag.
onClick?() => void—Click handler (used when href is not set).
Notes
- Magnification uses a Gaussian bell curve:
scale = (magnification − 1) × exp(−d² / (2 × distance²)) + 1, so icons taper smoothly from the cursor outward. - Each icon's scale is independently driven by a
useSpringon auseTransformof the sharedmouseXMotionValue — no re-renders during animation. - The dock uses
origin-bottomso icons scale upward from their base, matching macOS behavior. - Tooltips animate with a 150ms ease-in/out on hover.
- The dock container has
backdrop-blur-mdapplied — ensure there's a background element behind it for the blur to be visible.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useSpring,
useTransform,
type SpringOptions,
AnimatePresence,
} from "motion/react";
import { cn } from "@/lib/utils";
export interface DockItem {
icon: React.ReactNode;
label: string;
/** if provided, item renders as an `<a>` */
href?: string;
onClick?: () => void;
/** renders a visual separator after this item */
separator?: boolean;
}
export interface DockProps {
items: DockItem[];
/** @default 1.8 */
magnification?: number;
/** cursor radius (px) within which neighbors are magnified — @default 120 */
distance?: number;
/** @default 40 */
iconSize?: number;
/** @default 4 */
gap?: number;
/** @default 16 */
borderRadius?: number;
/** show labels permanently instead of on hover — @default false */
alwaysShowLabels?: boolean;
springOptions?: SpringOptions;
className?: string;
}
const DEFAULT_SPRING: SpringOptions = {
stiffness: 400,
damping: 25,
mass: 0.4,
};
function DockSeparator() {
return (
<div className="mx-1 flex items-center self-stretch">
<div className="h-6 w-px bg-foreground/10" />
</div>
);
}
function DockIcon({
item,
mouseX,
magnification,
distance,
iconSize,
borderRadius,
alwaysShowLabels,
springOptions,
onHover,
iconRef: externalIconRef,
}: {
item: DockItem;
mouseX: ReturnType<typeof useMotionValue<number>>;
magnification: number;
distance: number;
iconSize: number;
borderRadius: number;
alwaysShowLabels: boolean;
springOptions: SpringOptions;
onHover: (ref: React.RefObject<HTMLDivElement | null> | null) => void;
iconRef: React.RefObject<HTMLDivElement | null>;
}) {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const distanceFromMouse = useTransform(mouseX, (val) => {
const el = wrapperRef.current;
if (!el) return distance * 100;
const rect = el.getBoundingClientRect();
return Math.abs(val - (rect.left + rect.width / 2));
});
const gaussian = (d: number) =>
(magnification - 1) * Math.exp(-(d * d) / (2 * distance * distance)) + 1;
const widthRaw = useTransform(
distanceFromMouse,
(d) => iconSize * gaussian(d),
);
const heightRaw = useTransform(
distanceFromMouse,
(d) => iconSize * gaussian(d),
);
const width = useSpring(widthRaw, springOptions);
const height = useSpring(heightRaw, springOptions);
const Tag = item.href ? "a" : "button";
return (
// fixed height in-flow; width animates to push neighbors
<motion.div
ref={wrapperRef}
className="relative flex items-end justify-center"
style={{ width, height: iconSize }}
>
{/* absolute, anchored bottom so icon grows upward */}
<motion.div
ref={externalIconRef}
style={{ width, height, bottom: 0 }}
className="absolute"
>
<Tag
href={item.href}
onClick={item.onClick}
onMouseEnter={() => onHover(externalIconRef)}
onMouseLeave={() => onHover(null)}
aria-label={item.label}
style={{ borderRadius }}
className={cn(
"flex h-full w-full items-center justify-center",
"text-foreground/70 transition-colors duration-150",
"hover:bg-foreground/[0.06] hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/20",
"[&_svg]:size-[55%]",
)}
>
{item.icon}
</Tag>
</motion.div>
{alwaysShowLabels && (
<span className="mt-0.5 text-[10px] font-medium tracking-tight text-foreground/40 whitespace-nowrap pointer-events-none select-none leading-none">
{item.label}
</span>
)}
</motion.div>
);
}
export function Dock({
items,
magnification = 1.8,
distance = 120,
iconSize = 40,
gap = 4,
borderRadius = 16,
alwaysShowLabels = false,
springOptions = DEFAULT_SPRING,
className,
}: DockProps) {
const mouseX = useMotionValue(Infinity);
const dockRef = React.useRef<HTMLDivElement>(null);
const iconRefs = React.useRef<React.RefObject<HTMLDivElement | null>[]>(
items.map(() => React.createRef<HTMLDivElement>()),
);
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null);
const [tooltipX, setTooltipX] = React.useState(0);
const [tooltipBottomOffset, setTooltipBottomOffset] = React.useState(0);
React.useEffect(() => {
if (hoveredIndex === null) return;
let raf: number;
const update = () => {
const iconEl = iconRefs.current[hoveredIndex]?.current;
const dockEl = dockRef.current;
if (iconEl && dockEl) {
const iconRect = iconEl.getBoundingClientRect();
const dockRect = dockEl.getBoundingClientRect();
setTooltipX(iconRect.left - dockRect.left + iconRect.width / 2);
setTooltipBottomOffset(dockRect.bottom - iconRect.top);
}
raf = requestAnimationFrame(update);
};
raf = requestAnimationFrame(update);
return () => cancelAnimationFrame(raf);
}, [hoveredIndex]);
const handleHover = React.useCallback(
(ref: React.RefObject<HTMLDivElement | null> | null) => {
if (ref === null) {
setHoveredIndex(null);
return;
}
const idx = iconRefs.current.findIndex((r) => r === ref);
setHoveredIndex(idx >= 0 ? idx : null);
},
[],
);
return (
<motion.div
ref={dockRef}
className={cn(
"relative flex items-end overflow-visible border border-foreground/[0.08] bg-background/80 px-2 py-2 shadow-none hover:shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_2px_8px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06)] transition-shadow duration-200 backdrop-blur-xl",
className,
)}
style={{ gap, borderRadius }}
onMouseMove={(e) => mouseX.set(e.clientX)}
onMouseLeave={() => mouseX.set(Infinity)}
>
{items.map((item, i) => (
<React.Fragment key={i}>
<DockIcon
item={item}
mouseX={mouseX}
magnification={magnification}
distance={distance}
iconSize={iconSize}
borderRadius={borderRadius}
alwaysShowLabels={alwaysShowLabels}
springOptions={springOptions}
onHover={handleHover}
iconRef={iconRefs.current[i]}
/>
{item.separator && <DockSeparator />}
</React.Fragment>
))}
{!alwaysShowLabels && (
<AnimatePresence>
{hoveredIndex !== null && (
<motion.div
key="dock-tooltip"
layoutId="dock-tooltip"
className="pointer-events-none absolute flex flex-col items-center z-50"
style={{
left: tooltipX,
bottom: tooltipBottomOffset + 8,
x: "-50%",
}}
initial={{ opacity: 0, y: 6, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.94 }}
transition={{ duration: 0.13, ease: "easeOut" }}
>
<span className="rounded-md border border-foreground/10 bg-background px-2 py-1 text-sm font-medium text-foreground shadow-sm whitespace-nowrap">
{items[hoveredIndex].label}
</span>
<svg
width="8"
height="4"
viewBox="0 0 8 4"
className="-mt-px text-background"
aria-hidden
>
<path d="M0 0L4 4L8 0" fill="currentColor" />
</svg>
</motion.div>
)}
</AnimatePresence>
)}
</motion.div>
);
}