"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
export type TetherSide = "top" | "right" | "bottom" | "left";
export type TetherAlign = "start" | "center" | "end";
export type TetherOpenOn = "click" | "hover";
export interface TetherProps {
trigger?: ReactNode;
children?: ReactNode;
side?: TetherSide;
align?: TetherAlign;
offset?: number;
stiffness?: number;
damping?: number;
popScale?: number;
arrow?: boolean;
flip?: boolean;
openOn?: TetherOpenOn;
closeDelay?: number;
radius?: number;
accentColor?: string;
reducedMotion?: boolean;
className?: string;
panelClassName?: string;
}
const OPPOSITE: Record<TetherSide, TetherSide> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
function originFor(side: TetherSide, align: TetherAlign): string {
const along =
align === "start" ? "0%" : align === "end" ? "100%" : "50%";
switch (side) {
case "bottom":
return `${along} 0%`;
case "top":
return `${along} 100%`;
case "right":
return `0% ${along}`;
case "left":
return `100% ${along}`;
}
}
const NUDGE = 6;
const useIsoLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
export default function Tether({
trigger,
children,
side = "bottom",
align = "center",
offset = 10,
stiffness = 520,
damping = 26,
popScale = 0.86,
arrow = true,
flip = true,
openOn = "click",
closeDelay = 140,
radius = 10,
accentColor = "#a855f7",
reducedMotion = false,
className,
panelClassName,
}: TetherProps) {
const uid = useId();
const [open, setOpen] = useState(false);
const [flipped, setFlipped] = useState(false);
const [lastSide, setLastSide] = useState(side);
if (lastSide !== side) {
setLastSide(side);
setFlipped(false);
}
const resolved = flipped ? OPPOSITE[side] : side;
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const openedBy = useRef<TetherOpenOn>("click");
const clearClose = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = null;
};
useEffect(() => clearClose, []);
const place = useCallback(() => {
const triggerEl = triggerRef.current;
const panel = panelRef.current;
if (!triggerEl || !panel) return;
if (!flip) {
setFlipped(false);
return;
}
const box = triggerEl.getBoundingClientRect();
const w = panel.offsetWidth + offset;
const h = panel.offsetHeight + offset;
const room = {
top: box.top,
bottom: window.innerHeight - box.bottom,
left: box.left,
right: window.innerWidth - box.right,
};
const need = side === "top" || side === "bottom" ? h : w;
setFlipped(room[side] < need && room[OPPOSITE[side]] >= need);
}, [flip, offset, side]);
useIsoLayoutEffect(() => {
if (!open) return;
place();
}, [open, place]);
useEffect(() => {
if (!open) return;
const onScrollOrResize = () => place();
window.addEventListener("resize", onScrollOrResize);
window.addEventListener("scroll", onScrollOrResize, true);
return () => {
window.removeEventListener("resize", onScrollOrResize);
window.removeEventListener("scroll", onScrollOrResize, true);
};
}, [open, place]);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
triggerRef.current?.focus();
};
const onDown = (event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown);
};
}, [open]);
useEffect(() => {
if (open && openedBy.current === "click") {
panelRef.current?.focus({ preventScroll: true });
}
}, [open]);
const vertical = resolved === "top" || resolved === "bottom";
const nudge = reducedMotion ? 0 : NUDGE;
const from = {
x: resolved === "left" ? nudge : resolved === "right" ? -nudge : 0,
y: resolved === "top" ? nudge : resolved === "bottom" ? -nudge : 0,
};
const hover = openOn === "hover";
return (
<div
ref={rootRef}
className={cn("relative inline-block", className)}
onPointerEnter={
hover
? () => {
clearClose();
openedBy.current = "hover";
setOpen(true);
}
: undefined
}
onPointerLeave={
hover
? () => {
clearClose();
closeTimer.current = setTimeout(() => {
if (rootRef.current?.contains(document.activeElement)) return;
setOpen(false);
}, Math.max(0, closeDelay));
}
: undefined
}
>
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? `${uid}-panel` : undefined}
aria-describedby={open ? `${uid}-panel` : undefined}
onClick={() => {
if (hover) return;
openedBy.current = "click";
setOpen((v) => !v);
}}
onFocus={
hover
? () => {
clearClose();
openedBy.current = "hover";
setOpen(true);
}
: undefined
}
className="rounded-md border border-hairline bg-raised px-4 py-2 font-display text-[11px] tracking-[0.14em] text-ink uppercase transition-colors hover:bg-raised/70"
style={{ boxShadow: open ? `inset 0 0 0 1px ${accentColor}` : undefined }}
>
{trigger}
</button>
<AnimatePresence>
{open ? (
<div
key="tether-panel"
className={cn(
"absolute z-50",
resolved === "bottom" && "top-full",
resolved === "top" && "bottom-full",
resolved === "right" && "left-full",
resolved === "left" && "right-full",
vertical && align === "start" && "left-0",
vertical && align === "end" && "right-0",
vertical && align === "center" && "left-1/2 -translate-x-1/2",
!vertical && align === "start" && "top-0",
!vertical && align === "end" && "bottom-0",
!vertical && align === "center" && "top-1/2 -translate-y-1/2",
)}
style={{
paddingTop: resolved === "bottom" ? offset : undefined,
paddingBottom: resolved === "top" ? offset : undefined,
paddingLeft: resolved === "right" ? offset : undefined,
paddingRight: resolved === "left" ? offset : undefined,
}}
>
<motion.div
ref={panelRef}
id={`${uid}-panel`}
role="dialog"
aria-label="More"
tabIndex={-1}
initial={
reducedMotion ? false : { opacity: 0, scale: popScale, ...from }
}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={{ opacity: 0, scale: popScale, ...from }}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness, damping }
}
style={{
transformOrigin: originFor(resolved, align),
borderRadius: radius,
}}
className={cn(
"relative border border-hairline bg-panel shadow-xl outline-none",
panelClassName,
)}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute rounded-[inherit]",
resolved === "bottom" && "inset-x-0 top-0 h-0.5",
resolved === "top" && "inset-x-0 bottom-0 h-0.5",
resolved === "right" && "inset-y-0 left-0 w-0.5",
resolved === "left" && "inset-y-0 right-0 w-0.5",
)}
style={{ backgroundColor: accentColor }}
/>
{arrow ? (
<span
aria-hidden
className={cn(
"pointer-events-none absolute size-2 rotate-45",
resolved === "bottom" && "-top-1",
resolved === "top" && "-bottom-1",
resolved === "right" && "-left-1",
resolved === "left" && "-right-1",
vertical && align === "start" && "left-3.5",
vertical && align === "end" && "right-3.5",
vertical && align === "center" && "left-1/2 -ml-1",
!vertical && align === "start" && "top-3.5",
!vertical && align === "end" && "bottom-3.5",
!vertical && align === "center" && "top-1/2 -mt-1",
)}
style={{ backgroundColor: accentColor }}
/>
) : null}
{children}
</motion.div>
</div>
) : null}
</AnimatePresence>
</div>
);
}