"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "motion/react";
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,
);
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface MorphingDropdownItem {
id: string;
label: string;
/** Panel content for this trigger. Its intrinsic size drives the panel's size. */
content: React.ReactNode;
}
export interface MorphingDropdownProps extends Omit<React.HTMLAttributes<HTMLElement>, "defaultValue"> {
items: MorphingDropdownItem[];
/** Open menus when a mouse hovers a trigger (touch and keyboard always use click / Enter). */
openOnHover?: boolean;
/** Hover-intent delay before the first menu opens, in ms. */
openDelay?: number;
/** Grace period after the pointer leaves before the panel closes, in ms. */
closeDelay?: number;
/** Distance content slides when switching between triggers, in px. */
slideDistance?: number;
/** Show the arrow pointer above the panel. */
showArrow?: boolean;
/** Spring for the panel's size, position and arrow. */
spring?: Spring;
/** Accent for focus rings and the open trigger marker. */
accent?: string;
/** Accessible name of the nav landmark. */
label?: string;
/** Id of the menu open on first render (uncontrolled). */
defaultValue?: string | null;
/** Called when the open menu changes (null when closed). */
onValueChange?: (id: string | null) => void;
}
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
const EDGE = 8; // px kept between the panel and the viewport edge
/**
* A top-navigation dropdown with ONE floating panel that morphs its size and position between
* triggers while the content slides in the direction of travel. Disclosure semantics: each trigger
* is a button with aria-expanded / aria-controls. Hover intent for mice, click for touch, full
* keyboard support (Enter / ArrowDown opens, arrows move between triggers, Escape returns focus).
*/
export function MorphingDropdown({
items,
openOnHover = true,
openDelay = 120,
closeDelay = 240,
slideDistance = 48,
showArrow = true,
spring = { stiffness: 420, damping: 38, mass: 0.9 },
accent = "#FF7448",
label = "Main",
defaultValue = null,
onValueChange,
className = "",
style,
onPointerEnter,
onPointerLeave,
onKeyDown,
onBlur,
...props
}: MorphingDropdownProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const triggerRefs = React.useRef(new Map<string, HTMLButtonElement>());
const contentRefs = React.useRef(new Map<string, HTMLDivElement>());
const timers = React.useRef({ open: 0, close: 0 });
const hoverOpenedAt = React.useRef(0);
const focusContentRef = React.useRef(false);
const wasOpenRef = React.useRef(false);
const [active, setActive] = React.useState<string | null>(defaultValue);
const [direction, setDirection] = React.useState(0);
const width = useMotionValue(0);
const height = useMotionValue(0);
const x = useMotionValue(0);
const arrowX = useMotionValue(0);
const arrowLeft = useTransform(arrowX, (v) => v - 7);
const indexOf = (id: string | null) => items.findIndex((item) => item.id === id);
const contentId = (id: string) => `${uid}-content-${id}`;
const clearTimers = () => {
window.clearTimeout(timers.current.open);
window.clearTimeout(timers.current.close);
};
const open = (id: string) => {
clearTimers();
if (id === active) return;
setDirection(active === null ? 0 : Math.sign(indexOf(id) - indexOf(active)));
setActive(id);
onValueChange?.(id);
};
const close = (returnFocus = false) => {
clearTimers();
if (active === null) return;
if (returnFocus) triggerRefs.current.get(active)?.focus();
setActive(null);
onValueChange?.(null);
};
// Clean up timers on unmount.
React.useEffect(() => {
const t = timers.current;
return () => {
window.clearTimeout(t.open);
window.clearTimeout(t.close);
};
}, []);
/* ------------------------- measure and morph the panel ------------------------- */
const morph = React.useEffectEvent((id: string, instant: boolean) => {
const root = rootRef.current;
const content = contentRefs.current.get(id);
const trigger = triggerRefs.current.get(id);
if (!root || !content || !trigger) return;
const rootRect = root.getBoundingClientRect();
// Compensate for CSS transforms on ancestors (e.g. a zoomed preview stage).
const ratio = root.offsetWidth ? rootRect.width / root.offsetWidth || 1 : 1;
const t = trigger.getBoundingClientRect();
const w = content.offsetWidth;
const h = content.offsetHeight;
const center = (t.left + t.width / 2 - rootRect.left) / ratio;
const min = (EDGE - rootRect.left) / ratio;
const max = (document.documentElement.clientWidth - EDGE - rootRect.left) / ratio - w;
const left = Math.max(min, Math.min(center - w / 2, max));
const targets: [typeof width, number][] = [
[width, w],
[height, h],
[x, left],
[arrowX, Math.max(14, Math.min(center - left, w - 14))],
];
for (const [mv, value] of targets) {
if (instant || reduceMotion) mv.set(value);
else animate(mv, value, { type: "spring", ...spring });
}
});
React.useEffect(() => {
if (active === null) {
wasOpenRef.current = false;
return;
}
const content = contentRefs.current.get(active);
if (!content) return;
morph(active, !wasOpenRef.current);
wasOpenRef.current = true;
if (focusContentRef.current) {
focusContentRef.current = false;
content.querySelector<HTMLElement>(FOCUSABLE)?.focus();
}
const ro = new ResizeObserver(() => morph(active, false));
ro.observe(content);
const onResize = () => morph(active, true);
window.addEventListener("resize", onResize);
return () => {
ro.disconnect();
window.removeEventListener("resize", onResize);
};
}, [active]);
// Close on a pointer press outside the navigation.
React.useEffect(() => {
if (active === null) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
clearTimeout(timers.current.open);
setActive(null);
onValueChange?.(null);
}
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [active, onValueChange]);
/* ---------------------------------- handlers ---------------------------------- */
const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, id: string) => {
const index = indexOf(id);
const last = items.length - 1;
const moveTo = (nextIndex: number) => {
const next = items[nextIndex];
triggerRefs.current.get(next.id)?.focus();
if (active !== null) open(next.id);
};
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusContentRef.current = true;
if (active === id) {
focusContentRef.current = false;
contentRefs.current.get(id)?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
} else open(id);
break;
case "ArrowRight":
event.preventDefault();
moveTo(index === last ? 0 : index + 1);
break;
case "ArrowLeft":
event.preventDefault();
moveTo(index === 0 ? last : index - 1);
break;
case "Home":
event.preventDefault();
moveTo(0);
break;
case "End":
event.preventDefault();
moveTo(last);
break;
case "Tab":
// The shared panel sits after all triggers in the DOM: Tab from an open trigger goes into it.
if (!event.shiftKey && active === id) {
const first = contentRefs.current.get(id)?.querySelector<HTMLElement>(FOCUSABLE);
if (first) {
event.preventDefault();
first.focus();
}
}
break;
}
};
const handlePanelKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Tab" || active === null) return;
const content = contentRefs.current.get(active);
const focusables = content ? Array.from(content.querySelectorAll<HTMLElement>(FOCUSABLE)) : [];
if (!focusables.length) return;
const index = focusables.indexOf(document.activeElement as HTMLElement);
if (event.shiftKey && index === 0) {
// Back to the trigger that owns this content.
event.preventDefault();
triggerRefs.current.get(active)?.focus();
} else if (!event.shiftKey && index === focusables.length - 1) {
// Continue with the trigger after the owner, or leave the navigation.
const next = items[indexOf(active) + 1];
if (next) {
event.preventDefault();
triggerRefs.current.get(next.id)?.focus();
close();
}
}
};
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const slide = reduceMotion ? 0 : slideDistance;
// AnimatePresence keeps the last rendered panel during its exit, so only the open item is needed.
const shownItem = items.find((item) => item.id === active);
return (
<nav
ref={rootRef}
aria-label={label}
className={`relative ${className}`}
style={{ ["--md-accent" as string]: accent, ...style }}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (event.pointerType === "mouse") window.clearTimeout(timers.current.close);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
if (event.pointerType !== "mouse" || !openOnHover) return;
window.clearTimeout(timers.current.open);
if (active !== null) timers.current.close = window.setTimeout(() => close(), closeDelay);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.key === "Escape" && active !== null) {
event.preventDefault();
close(true);
}
}}
onBlur={(event) => {
onBlur?.(event);
// Focus left the navigation entirely.
const next = event.relatedTarget as Node | null;
if (next && !event.currentTarget.contains(next)) close();
}}
{...props}
>
<ul className="flex items-center gap-1">
{items.map((item) => {
const expanded = active === item.id;
return (
<li key={item.id}>
<button
ref={(node) => {
if (node) triggerRefs.current.set(item.id, node);
else triggerRefs.current.delete(item.id);
}}
type="button"
aria-expanded={expanded}
aria-controls={expanded ? contentId(item.id) : undefined}
onPointerEnter={(event) => {
if (event.pointerType !== "mouse" || !openOnHover) return;
window.clearTimeout(timers.current.close);
window.clearTimeout(timers.current.open);
if (active !== null) open(item.id);
else
timers.current.open = window.setTimeout(() => {
hoverOpenedAt.current = performance.now();
open(item.id);
}, openDelay);
}}
onPointerLeave={() => window.clearTimeout(timers.current.open)}
onClick={() => {
if (expanded) {
// A click right after hover-intent opened this menu should not immediately close it.
if (performance.now() - hoverOpenedAt.current > 400) close();
} else open(item.id);
}}
onKeyDown={(event) => handleTriggerKeyDown(event, item.id)}
className={`group inline-flex h-9 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium tracking-tight transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--md-accent)] ${
expanded
? "bg-zinc-900/[0.06] text-zinc-950 dark:bg-white/[0.08] dark:text-white"
: "text-zinc-600 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white"
}`}
>
{item.label}
<svg
viewBox="0 0 12 12"
aria-hidden="true"
fill="none"
className={`h-3 w-3 opacity-60 transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
>
<path d="M3 4.5 6 7.5l3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</li>
);
})}
</ul>
<AnimatePresence>
{active !== null && shownItem && (
<motion.div
key="panel"
initial={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6, scale: reduceMotion ? 1 : 0.98, transition: { duration: 0.16 } }}
transition={reduceMotion ? { duration: 0.12 } : { duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
onKeyDown={handlePanelKeyDown}
// The invisible ::before bridges the gap between the triggers and the panel for the pointer.
className="absolute left-0 top-full z-50 mt-3 origin-top before:absolute before:inset-x-0 before:-top-3 before:h-3"
style={{ x, width, height }}
>
{showArrow && (
<motion.span
aria-hidden="true"
className="absolute -top-[7px] z-10 h-3.5 w-3.5 rotate-45 rounded-tl-[3px] border-l border-t border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900"
style={{ left: arrowLeft }}
/>
)}
<div className="absolute inset-0 overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-zinc-900 shadow-[0_24px_60px_-24px_rgba(0,0,0,0.3),0_2px_6px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_24px_60px_-20px_rgba(0,0,0,0.9)]">
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={shownItem.id}
id={contentId(shownItem.id)}
ref={(node: HTMLDivElement | null) => {
if (node) contentRefs.current.set(shownItem.id, node);
else contentRefs.current.delete(shownItem.id);
}}
custom={direction}
variants={{
enter: (d: number) => ({ opacity: 0, x: d * slide }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: d * -slide }),
}}
initial="enter"
animate="center"
exit="exit"
transition={reduceMotion ? { duration: 0.12 } : { ...transition, opacity: { duration: 0.18 } }}
className="absolute left-0 top-0 w-max max-w-[calc(100vw-16px)]"
>
{shownItem.content}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</nav>
);
}