"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useMotionValueEvent,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
/* SSR-safe: renders "no preference" on the server and the first client pass, so markup always hydrates. */
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface DockItem {
/** Accessible name, also shown in the tooltip. */
label: string;
/** Icon element (an <svg> scales with the tile). */
icon: React.ReactNode;
/** Renders the item as a link. */
href?: string;
/** Click handler (renders a button when there is no href). */
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
/** Marks the current item with an indicator and aria-current. */
active?: boolean;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface InteractiveDockProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
items: DockItem[];
/** Scale of the tile directly under the pointer (1 = no magnification). */
magnification?: number;
/** Distance in px from the pointer over which neighbours are magnified. */
distance?: number;
/** Resting tile size in px. */
size?: number;
/** Spring used to settle tile sizes. */
spring?: Spring;
/** Accent colour for the active indicator and focus ring. */
accent?: string;
/** Shape of the active indicator. */
indicator?: "dot" | "bar";
/** Accessible name of the navigation landmark. */
label?: string;
}
/* --------------------------- pointer capability --------------------------- */
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
function subscribeFinePointer(onChange: () => void) {
const mq = window.matchMedia(FINE_POINTER_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function useFinePointer() {
return React.useSyncExternalStore(
subscribeFinePointer,
() => window.matchMedia(FINE_POINTER_QUERY).matches,
() => true,
);
}
/* ---------------------------------- tiles --------------------------------- */
const TILE =
"relative flex h-full w-full items-center justify-center rounded-[30%] border border-zinc-900/10 bg-gradient-to-b from-white to-zinc-100 text-zinc-700 " +
"shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_1px_2px_rgba(0,0,0,0.06)] transition-colors hover:text-zinc-950 " +
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--dock-accent)] " +
"aria-[current]:text-zinc-950 dark:border-white/10 dark:from-zinc-800 dark:to-zinc-900 dark:text-zinc-300 " +
"dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:text-white dark:aria-[current]:text-white " +
"[&_svg]:h-[46%] [&_svg]:w-[46%]";
function DockLink({ item, className, ...rest }: { item: DockItem; className: string } & React.HTMLAttributes<HTMLElement>) {
const current = item.active ? (item.href ? "page" : "true") : undefined;
const content = (
<span aria-hidden="true" className="pointer-events-none flex h-full w-full items-center justify-center">
{item.icon}
</span>
);
return item.href ? (
<a data-dock-item="" href={item.href} aria-label={item.label} aria-current={current} onClick={item.onClick} className={className} {...rest}>
{content}
</a>
) : (
<button data-dock-item="" type="button" aria-label={item.label} aria-current={current} onClick={item.onClick} className={className} {...rest}>
{content}
</button>
);
}
function Indicator({ shape, visible, className }: { shape: "dot" | "bar"; visible: boolean; className: string }) {
return (
<span
aria-hidden="true"
className={`pointer-events-none absolute left-1/2 -translate-x-1/2 rounded-full bg-[var(--dock-accent)] transition-[opacity,scale] duration-300 ${
shape === "dot" ? "h-1 w-1" : "h-[3px] w-3.5"
} ${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"} ${className}`}
/>
);
}
interface MagnifiedTileProps {
item: DockItem;
index: number;
pointerX: MotionValue<number>;
size: number;
magnification: number;
distance: number;
spring: Spring;
indicator: "dot" | "bar";
}
function MagnifiedTile({ item, index, pointerX, size, magnification, distance, spring, indicator }: MagnifiedTileProps) {
const ref = React.useRef<HTMLLIElement>(null);
// Signed distance from the pointer to this tile's centre, updated from pointer events only.
const offset = useMotionValue(Infinity);
useMotionValueEvent(pointerX, "change", (x) => {
const el = ref.current;
if (!el || !Number.isFinite(x)) {
offset.set(Infinity);
return;
}
const rect = el.getBoundingClientRect();
offset.set(x - (rect.left + rect.width / 2));
});
const target = useTransform(offset, [-distance, 0, distance], [size, size * magnification, size]);
const width = useSpring(target, spring);
const focusMagnify = (event: React.FocusEvent<HTMLElement>) => {
// Keyboard focus magnifies like the pointer would; mouse clicks keep the pointer in charge.
if (!event.currentTarget.matches(":focus-visible")) return;
const rect = event.currentTarget.getBoundingClientRect();
pointerX.set(rect.left + rect.width / 2);
};
return (
<li ref={ref} className="group/tile relative flex shrink-0 flex-col items-center justify-end">
<Tooltip label={item.label} index={index} />
<motion.div style={{ width, height: width }} className="relative">
<DockLink item={item} className={TILE} onFocus={focusMagnify} onBlur={() => pointerX.set(Infinity)} />
</motion.div>
<Indicator shape={indicator} visible={!!item.active} className="-bottom-[6px]" />
</li>
);
}
function Tooltip({ label, index }: { label: string; index: number }) {
return (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-full left-1/2 mb-3 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-md border border-zinc-900/10 bg-white px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-700 opacity-0 shadow-[0_6px_16px_-8px_rgba(0,0,0,0.25)] transition-[opacity,translate] duration-150 group-hover/tile:translate-y-0 group-hover/tile:opacity-100 group-has-[:focus-visible]/tile:translate-y-0 group-has-[:focus-visible]/tile:opacity-100 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300"
>
<span className="text-zinc-400 dark:text-zinc-500">{String(index + 1).padStart(2, "0")}</span>
<span className="mx-1.5 text-zinc-300 dark:text-zinc-600">/</span>
{label}
</span>
);
}
/* ---------------------------------- dock ---------------------------------- */
/** Moves focus between dock items with the arrow keys, Home and End. */
function handleArrowKeys(event: React.KeyboardEvent<HTMLElement>) {
const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
if (!keys.includes(event.key)) return;
const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[data-dock-item]"));
const current = items.indexOf(document.activeElement as HTMLElement);
if (current === -1) return;
event.preventDefault();
const last = items.length - 1;
const next =
event.key === "Home" ? 0 : event.key === "End" ? last : event.key === "ArrowRight" ? (current + 1) % items.length : (current - 1 + items.length) % items.length;
items[next]?.focus();
}
/**
* A navigation dock whose tiles grow as the pointer approaches. On touch devices, and with
* reduced motion, it becomes a compact, horizontally scrollable bar with visible labels.
*/
export function InteractiveDock({
items,
magnification = 1.7,
distance = 140,
size = 44,
spring = { stiffness: 320, damping: 24, mass: 0.4 },
accent = "#FF7448",
indicator = "dot",
label = "Dock",
className = "",
style,
...props
}: InteractiveDockProps) {
const finePointer = useFinePointer();
const reduceMotion = usePrefersReducedMotion();
const pointerX = useMotionValue(Infinity);
const magnify = finePointer && !reduceMotion;
const accentStyle = { ["--dock-accent" as string]: accent, ...style };
if (!magnify) {
// Compact alternative: fixed tiles, labels always visible, scrolls horizontally if it overflows.
return (
<nav aria-label={label} className={`max-w-full ${className}`} style={accentStyle} {...props}>
<ul
onKeyDown={handleArrowKeys}
className="flex snap-x gap-1 overflow-x-auto rounded-2xl border border-zinc-900/10 bg-white/95 p-1.5 [scrollbar-width:none] dark:border-white/10 dark:bg-zinc-900/95"
>
{items.map((item) => (
<li key={item.label} className="relative flex shrink-0 snap-start flex-col items-center gap-1 px-1 pb-2 pt-1">
<div style={{ width: size, height: size }}>
<DockLink item={item} className={TILE} />
</div>
<span aria-hidden="true" className="max-w-[4.5rem] truncate font-mono text-[9px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400">
{item.label}
</span>
<Indicator shape={indicator} visible={!!item.active} className="bottom-0.5" />
</li>
))}
</ul>
</nav>
);
}
return (
<nav aria-label={label} className={className} style={accentStyle} {...props}>
<ul
onPointerMove={(event) => {
if (event.pointerType === "mouse") pointerX.set(event.clientX);
}}
onPointerLeave={() => pointerX.set(Infinity)}
onKeyDown={handleArrowKeys}
// Fixed bar height: magnified tiles rise above the bar instead of stretching it.
style={{ height: size + 20 }}
className="relative flex items-end gap-2.5 rounded-[20px] border border-zinc-900/10 bg-white/90 px-2.5 pb-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.8),0_18px_40px_-20px_rgba(0,0,0,0.35)] backdrop-blur-sm dark:border-white/10 dark:bg-zinc-950/80 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05),0_18px_40px_-20px_rgba(0,0,0,0.8)]"
>
{/* Hairline baseline — an engineered detail that also anchors the indicators. */}
<span aria-hidden="true" className="pointer-events-none absolute inset-x-4 bottom-[5px] h-px bg-zinc-900/[0.06] dark:bg-white/[0.06]" />
{items.map((item, index) => (
<MagnifiedTile
key={item.label}
item={item}
index={index}
pointerX={pointerX}
size={size}
magnification={magnification}
distance={distance}
spring={spring}
indicator={indicator}
/>
))}
</ul>
</nav>
);
}