"use client";
import * as React from "react";
import { animate, motion, useMotionValue, type MotionValue } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface HoverHighlightNavItem {
/** Unique id. */
id: string;
label: string;
/** Renders an <a>; without it the item is a <button>. */
href?: string;
/** Optional leading icon. */
icon?: React.ReactNode;
/** Optional trailing badge, e.g. a count. */
badge?: React.ReactNode;
}
export interface HoverHighlightNavProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
items: HoverHighlightNavItem[];
/** Active item id (controlled). */
activeId?: string;
/** Initially active item id (uncontrolled). */
defaultActiveId?: string;
/** Called when an item is clicked. Call event.preventDefault() for client-side routing. */
onActiveChange?: (id: string, event: React.MouseEvent<HTMLElement>) => void;
/** Lay links out in a row or a column. */
orientation?: "horizontal" | "vertical";
/** Highlight pill style. */
variant?: "soft" | "outline" | "tint";
/** Separate accent underline (or side bar when vertical) under the active link. */
showUnderline?: boolean;
/** When the pointer leaves, the pill glides back to the active link; otherwise it fades out. */
returnToActive?: boolean;
/** Spring for the pill and the underline. */
spring?: Spring;
/** Underline and focus colour (also tints the "tint" pill). */
accent?: string;
/** Accessible name of the navigation landmark. */
label?: string;
}
interface Box {
x: number;
y: number;
w: number;
h: number;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const UNDERLINE_INSET = 10; // px trimmed from each end of the underline
/**
* Navigation links with one highlight pill that glides to whichever link is hovered or keyboard-focused
* and rests on the active link when the pointer leaves, plus a separate active underline. Link boxes are
* measured once (and on resize) so pointer movement only drives springs on motion values: no layout reads
* or React renders per hover. Touch input never moves the pill; it follows the active link only.
*/
export function HoverHighlightNav({
items,
activeId,
defaultActiveId,
onActiveChange,
orientation = "horizontal",
variant = "soft",
showUnderline = true,
returnToActive = true,
spring = { stiffness: 420, damping: 34, mass: 0.6 },
accent = "#FF7448",
label = "Main",
className = "",
style,
onPointerLeave,
onBlur,
...props
}: HoverHighlightNavProps) {
const reduceMotion = usePrefersReducedMotion();
const listRef = React.useRef<HTMLUListElement>(null);
const itemRefs = React.useRef(new Map<string, HTMLElement>());
const boxesRef = React.useRef(new Map<string, Box>());
const targetRef = React.useRef<string | null>(null); // hovered / focused id
const pillShownRef = React.useRef(false);
const [internal, setInternal] = React.useState(defaultActiveId ?? items[0]?.id);
const active = activeId ?? internal;
const vertical = orientation === "vertical";
const pill = { x: useMotionValue(0), y: useMotionValue(0), w: useMotionValue(0), h: useMotionValue(0) };
const pillOpacity = useMotionValue(0);
const line = { x: useMotionValue(0), y: useMotionValue(0), w: useMotionValue(0), h: useMotionValue(0) };
const lineOpacity = useMotionValue(0);
/* ------------------------------- positioning ------------------------------- */
const measure = () => {
const boxes = new Map<string, Box>();
for (const [id, el] of itemRefs.current) boxes.set(id, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
boxesRef.current = boxes;
};
const springTo = (values: Record<string, MotionValue<number>>, box: Record<string, number>, instant: boolean) => {
for (const key of Object.keys(values)) {
if (instant || reduceMotion) {
values[key].jump(box[key]);
} else {
animate(values[key], box[key], { type: "spring", ...spring });
}
}
};
const underlineBox = (box: Box) =>
vertical
? { x: 0, y: box.y + UNDERLINE_INSET / 2, w: 2, h: Math.max(0, box.h - UNDERLINE_INSET) }
: { x: box.x + UNDERLINE_INSET, y: box.y + box.h - 2, w: Math.max(0, box.w - UNDERLINE_INSET * 2), h: 2 };
/** Moves the pill to the hovered / focused link, or back to the active one (or fades it out). */
const placePill = (instant = false) => {
const id = targetRef.current ?? (returnToActive ? active : null);
const box = id ? boxesRef.current.get(id) : undefined;
if (!box) {
pillShownRef.current = false;
if (reduceMotion || instant) pillOpacity.jump(0);
else animate(pillOpacity, 0, { duration: 0.15 });
return;
}
// Appearing from nothing: start at the link instead of sliding in from a stale spot.
const appear = !pillShownRef.current;
pillShownRef.current = true;
springTo(pill, { x: box.x, y: box.y, w: box.w, h: box.h }, instant || appear);
if (reduceMotion || instant) pillOpacity.jump(1);
else animate(pillOpacity, 1, { duration: appear ? 0.15 : 0.1 });
};
const placeUnderline = (instant = false) => {
const box = active ? boxesRef.current.get(active) : undefined;
if (!box) {
lineOpacity.jump(0);
return;
}
springTo(line, underlineBox(box), instant);
lineOpacity.jump(1);
};
const sync = React.useEffectEvent((instant: boolean) => {
measure();
placePill(instant);
placeUnderline(instant);
});
// Measure before paint on mount and whenever the item set or layout props change; re-measure on resize.
React.useLayoutEffect(() => {
sync(true);
const list = listRef.current;
if (!list) return;
const ro = new ResizeObserver(() => sync(true));
ro.observe(list);
itemRefs.current.forEach((el) => ro.observe(el));
return () => ro.disconnect();
}, [items, orientation]);
const onActiveMoved = React.useEffectEvent(() => {
placeUnderline(false);
placePill(false);
});
React.useEffect(() => {
onActiveMoved();
}, [active]);
/* --------------------------------- events ---------------------------------- */
const target = (id: string | null) => {
targetRef.current = id;
placePill();
};
const handleClick = (event: React.MouseEvent<HTMLElement>, id: string) => {
if (activeId === undefined) setInternal(id);
onActiveChange?.(id, event);
};
/* --------------------------------- render ---------------------------------- */
const pillClass =
variant === "outline"
? "border border-zinc-900/15 bg-white/60 dark:border-white/15 dark:bg-white/[0.03]"
: variant === "tint"
? "bg-[color-mix(in_oklab,var(--hh-accent)_14%,transparent)] dark:bg-[color-mix(in_oklab,var(--hh-accent)_20%,transparent)]"
: "bg-zinc-900/[0.06] dark:bg-white/[0.08]";
return (
<nav
aria-label={label}
className={`relative ${className}`}
style={{ ["--hh-accent" as string]: accent, ...style }}
onPointerLeave={(event) => {
if (targetRef.current !== null) target(null);
onPointerLeave?.(event);
}}
onBlur={(event) => {
// Focus left the nav entirely: release the pill.
const next = event.relatedTarget as Node | null;
if (!next || !event.currentTarget.contains(next)) target(null);
onBlur?.(event);
}}
{...props}
>
<ul ref={listRef} className={`relative flex ${vertical ? "flex-col gap-0.5" : "items-center gap-1"}`}>
<motion.span
aria-hidden="true"
className={`pointer-events-none absolute left-0 top-0 rounded-lg ${pillClass}`}
style={{ x: pill.x, y: pill.y, width: pill.w, height: pill.h, opacity: pillOpacity }}
/>
{showUnderline && (
<motion.span
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-20 rounded-full bg-[var(--hh-accent)]"
style={{ x: line.x, y: line.y, width: line.w, height: line.h, opacity: lineOpacity }}
/>
)}
{items.map((item) => {
const isActive = item.id === active;
const shared = {
ref: (el: HTMLElement | null) => {
if (el) itemRefs.current.set(item.id, el);
else itemRefs.current.delete(item.id);
},
"aria-current": isActive ? ("page" as const) : undefined,
onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
// Touch and pen have no hover: the pill stays on the active link.
if (event.pointerType === "mouse") target(item.id);
},
onFocus: (event: React.FocusEvent<HTMLElement>) => {
if (event.currentTarget.matches(":focus-visible")) target(item.id);
},
onClick: (event: React.MouseEvent<HTMLElement>) => handleClick(event, item.id),
className: `relative z-10 flex items-center gap-2 rounded-lg text-[13px] font-medium tracking-tight transition-colors duration-150 outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--hh-accent)] ${
vertical ? "h-9 w-full px-3" : "h-9 px-3.5"
} ${
isActive
? "text-zinc-950 dark:text-white"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}`,
};
const content = (
<>
{item.icon && (
<span aria-hidden="true" className={`inline-flex shrink-0 [&_svg]:size-4 ${isActive ? "text-[var(--hh-accent)]" : ""}`}>
{item.icon}
</span>
)}
<span className={vertical ? "min-w-0 flex-1 truncate text-left" : "whitespace-nowrap"}>{item.label}</span>
{item.badge !== undefined && (
<span className="rounded-full bg-zinc-900/[0.06] px-1.5 py-px font-mono text-[10px] tabular-nums text-zinc-600 dark:bg-white/[0.08] dark:text-zinc-300">
{item.badge}
</span>
)}
</>
);
return (
<li key={item.id} className={vertical ? "w-full" : ""}>
{item.href !== undefined ? (
<a href={item.href} {...shared}>
{content}
</a>
) : (
<button type="button" {...shared}>
{content}
</button>
)}
</li>
);
})}
</ul>
</nav>
);
}