"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DockItem {
id: string;
label: string;
icon: LucideIcon;
onClick?: () => void;
href?: string;
/** Shows the running dot below the icon. */
active?: boolean;
/** Tile colour classes, e.g. "bg-gradient-to-br from-sky-400 to-indigo-500 text-white". */
className?: string;
/** Draw a divider before this item. */
separatorBefore?: boolean;
}
export interface DockMagnifyProps {
items: DockItem[];
/** Resting icon size in px. */
size?: number;
/** Size of the icon directly under the cursor. */
magnification?: number;
/** Cursor distance (px) over which neighbours are affected. */
distance?: number;
/** Accessible name of the toolbar. */
label?: string;
className?: string;
}
export function DockMagnify({
items,
size = 44,
magnification = 72,
distance = 140,
label = "Dock",
className,
}: DockMagnifyProps) {
const reduce = useReducedMotion();
// Horizontal cursor position; Infinity = nothing hovered.
const mouseX = useMotionValue(Infinity);
const btnRefs = React.useRef<(HTMLElement | null)[]>([]);
const [rove, setRove] = React.useState(0);
// Shrink the resting size so the dock always fits its container (e.g. phones).
const barRef = React.useRef<HTMLDivElement>(null);
const [fit, setFit] = React.useState(size);
React.useEffect(() => {
const parent = barRef.current?.parentElement;
if (!parent) return;
const separators = items.filter((i) => i.separatorBefore).length;
const ro = new ResizeObserver(() => {
const bar = barRef.current;
if (!bar) return;
const gap = parseFloat(getComputedStyle(bar).columnGap) || 8;
const chrome = 22 + gap * (items.length - 1 + separators * 2) + separators;
const ps = getComputedStyle(parent);
const avail = parent.clientWidth - parseFloat(ps.paddingLeft) - parseFloat(ps.paddingRight);
setFit(Math.max(24, Math.min(size, Math.floor((avail - chrome) / items.length))));
});
ro.observe(parent);
return () => ro.disconnect();
}, [items, size]);
const base = fit;
const peak = Math.max(base, magnification * (base / size));
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const i = btnRefs.current.findIndex((el) => el === document.activeElement);
if (i < 0) return;
let next = -1;
if (e.key === "ArrowRight") next = (i + 1) % items.length;
else if (e.key === "ArrowLeft") next = (i - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
if (next < 0) return;
e.preventDefault();
btnRefs.current[next]?.focus();
};
return (
<div
ref={barRef}
role="toolbar"
aria-label={label}
onKeyDown={onKeyDown}
onPointerMove={(e) => e.pointerType === "mouse" && !reduce && mouseX.set(e.clientX)}
onPointerLeave={() => mouseX.set(Infinity)}
className={cn(
"mx-auto flex w-max max-w-full items-end gap-2 rounded-2xl border border-white/20 bg-background/60 px-2.5 pb-2 pt-2 shadow-2xl shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-background/50 dark:shadow-black/40",
className,
)}
style={{ height: base + 20 }}
>
{items.map((item, i) => (
<React.Fragment key={item.id}>
{item.separatorBefore && <span aria-hidden className="mx-1 w-px self-stretch bg-foreground/15" />}
<DockButton
item={item}
mouseX={mouseX}
size={base}
magnification={reduce ? base : peak}
distance={distance}
tabIndex={i === rove ? 0 : -1}
onFocused={() => setRove(i)}
refCb={(el) => {
btnRefs.current[i] = el;
}}
/>
</React.Fragment>
))}
</div>
);
}
interface DockButtonProps {
item: DockItem;
mouseX: MotionValue<number>;
size: number;
magnification: number;
distance: number;
tabIndex: number;
onFocused: () => void;
refCb: (el: HTMLElement | null) => void;
}
function DockButton({ item, mouseX, size, magnification, distance, tabIndex, onFocused, refCb }: DockButtonProps) {
const ref = React.useRef<HTMLElement | null>(null);
const [hover, setHover] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const focusBoost = useMotionValue(0);
const dist = useTransform(mouseX, (x) => {
const r = ref.current?.getBoundingClientRect();
if (!r || !Number.isFinite(x)) return distance * 2;
return x - (r.left + r.width / 2);
});
const hoverSize = useTransform(dist, [-distance, 0, distance], [size, magnification, size], { clamp: true });
const focusSize = useTransform(focusBoost, [0, 1], [size, size + (magnification - size) * 0.7]);
const target = useTransform<number, number>([hoverSize, focusSize], ([h, f]) => Math.max(h, f));
const width = useSpring(target, { stiffness: 320, damping: 22, mass: 0.35 });
const iconSize = useTransform(width, (w) => w * 0.5);
const Icon = item.icon;
const showTip = hover || focused;
const common = {
ref: (el: HTMLElement | null) => {
ref.current = el;
refCb(el);
},
"aria-label": item.active ? `${item.label} (open)` : item.label,
tabIndex,
onPointerEnter: () => setHover(true),
onPointerLeave: () => setHover(false),
onFocus: () => {
onFocused();
setFocused(true);
focusBoost.set(1);
},
onBlur: () => {
setFocused(false);
focusBoost.set(0);
},
className: cn(
"relative grid aspect-square place-items-center rounded-[28%] shadow-md outline-none ring-offset-2 ring-offset-transparent transition-shadow focus-visible:ring-2 focus-visible:ring-ring",
item.className ?? "bg-muted text-foreground",
),
};
const inner = (
<>
<motion.span style={{ width: iconSize, height: iconSize }} className="grid place-items-center">
<Icon className="size-full" strokeWidth={1.75} />
</motion.span>
<AnimatePresence>
{showTip && (
<motion.span
role="tooltip"
initial={{ opacity: 0, y: 6, x: "-50%", scale: 0.9 }}
animate={{ opacity: 1, y: 0, x: "-50%", scale: 1 }}
exit={{ opacity: 0, y: 4, x: "-50%", scale: 0.95, transition: { duration: 0.1 } }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="pointer-events-none absolute -top-9 left-1/2 whitespace-nowrap rounded-md border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md"
>
{item.label}
</motion.span>
)}
</AnimatePresence>
{item.active && <span aria-hidden className="absolute -bottom-[7px] left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground/70" />}
</>
);
return (
<motion.div style={{ width }} className="relative flex shrink-0 flex-col items-center">
{item.href ? (
<a href={item.href} {...common} aria-current={item.active ? "page" : undefined} style={{ width: "100%" }}>
{inner}
</a>
) : (
<button type="button" onClick={item.onClick} {...common} style={{ width: "100%" }}>
{inner}
</button>
)}
</motion.div>
);
}