"use client";
import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useSpring } from "motion/react";
export interface LinkPreviewData {
title: string;
description?: string;
/** Image URL or data URI shown at the top of the card. */
image?: string;
/** Alt text for the image; leave empty when it is decorative. */
imageAlt?: string;
/** Small mono line, e.g. the domain or "Article · 6 min". Defaults to the link's hostname. */
meta?: string;
}
export interface LinkPreviewProps extends Omit<React.ComponentPropsWithoutRef<"a">, "children"> {
href: string;
/** Link text. */
children: React.ReactNode;
/** Card content — provided by you; nothing is fetched. */
preview: LinkPreviewData;
/** Preferred side. Flips automatically when there is not enough room. */
side?: "top" | "bottom";
/** Hover / focus delay before the card opens, in ms. */
openDelay?: number;
/** Delay before closing after the pointer leaves, so it can travel into the card, in ms. */
closeDelay?: number;
/** Let the card drift slightly with the pointer along the link. */
followCursor?: boolean;
/** Card width in px (capped to the viewport). */
width?: number;
/** Underline and focus colour. */
accent?: string;
/** Label of the "open" link shown in the card after a first tap on touch screens. */
openLabel?: string;
}
const GAP = 10; // px between link and card
const EDGE = 8; // px kept from the viewport edge
const FOLLOW = 0.14; // fraction of the pointer offset the card follows
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** Hostname of absolute URLs ("motion.dev"); relative hrefs are shown as written. */
function hostOf(href: string) {
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(href)) return href;
try {
return new URL(href).hostname.replace(/^www\./, "");
} catch {
return href;
}
}
/**
* An inline link that shows a floating preview card on hover (after a delay) or keyboard focus.
* The card lives in the top layer (Popover API), flips above / below to stay in view, springs out
* of the link and stays open while the pointer moves into it. On touch, the first tap previews and
* the second tap follows the link.
*/
export function LinkPreview({
href,
children,
preview,
side = "top",
openDelay = 350,
closeDelay = 180,
followCursor = true,
width = 300,
accent = "#FF7448",
openLabel = "Open link",
className = "",
style,
onPointerEnter,
onPointerLeave,
onPointerMove,
onPointerDown,
onFocus,
onBlur,
onClick,
...props
}: LinkPreviewProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const descId = `${uid}-desc`;
const [open, setOpen] = React.useState(false);
/** Opened by a tap: the card becomes interactive and offers an explicit "open" link. */
const [touchMode, setTouchMode] = React.useState(false);
const anchorRef = React.useRef<HTMLAnchorElement>(null);
const cardRef = React.useRef<HTMLSpanElement | null>(null);
const openTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const closeTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pointerType = React.useRef("mouse");
const pointerY = React.useRef<number | null>(null);
const geometry = React.useRef({ cx: 0, min: 0, max: 0 });
const followRaw = useMotionValue(0);
const followX = useSpring(followRaw, { stiffness: 260, damping: 26 });
const host = hostOf(href);
const clearTimers = () => {
clearTimeout(openTimer.current);
clearTimeout(closeTimer.current);
};
const scheduleOpen = (delay: number) => {
clearTimeout(closeTimer.current);
if (open) return;
clearTimeout(openTimer.current);
openTimer.current = setTimeout(() => setOpen(true), Math.max(0, delay));
};
const hide = () => {
clearTimers();
setOpen(false);
setTouchMode(false);
};
const scheduleClose = (delay: number) => {
clearTimeout(openTimer.current);
clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(hide, Math.max(0, delay));
};
/** Places the card above or below the link, flipping and clamping to stay inside the viewport. */
const position = React.useCallback(() => {
const card = cardRef.current;
const anchor = anchorRef.current;
if (!card || !anchor) return;
// Wrapped links have several line boxes: use the one under the pointer, else the first.
const rects = Array.from(anchor.getClientRects());
const y = pointerY.current;
const rect =
(y !== null ? rects.find((r) => y >= r.top - 2 && y <= r.bottom + 2) : undefined) ?? rects[0] ?? anchor.getBoundingClientRect();
const w = card.offsetWidth;
const h = card.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const roomAbove = rect.top - GAP - EDGE;
const roomBelow = vh - rect.bottom - GAP - EDGE;
const above = side === "top" ? roomAbove >= h || roomAbove >= roomBelow : !(roomBelow >= h || roomBelow >= roomAbove);
const cx = rect.left + rect.width / 2;
const left = Math.min(Math.max(EDGE, cx - w / 2), Math.max(EDGE, vw - w - EDGE));
const top = above ? rect.top - GAP - h : rect.bottom + GAP;
card.style.left = `${left}px`;
card.style.top = `${top}px`;
card.style.transformOrigin = `${Math.min(w, Math.max(0, cx - left))}px ${above ? "100%" : "0%"}`;
card.dataset.side = above ? "top" : "bottom";
geometry.current = { cx, min: EDGE - left, max: vw - EDGE - w - left };
}, [side]);
/** Mounts the card into the top layer and positions it before the first paint of the animation. */
const mountCard = React.useCallback(
(node: HTMLSpanElement | null) => {
cardRef.current = node;
if (!node) return;
if (typeof node.showPopover === "function" && !node.matches(":popover-open")) {
try {
node.showPopover();
} catch {
// Already shown or detached: the fixed-position fallback still works.
}
}
position();
},
[position],
);
// While open: keep the card attached on scroll / resize, close on Escape and outside taps.
React.useEffect(() => {
if (!open) return;
let frame = 0;
const reposition = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(position);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
clearTimeout(openTimer.current);
clearTimeout(closeTimer.current);
setOpen(false);
setTouchMode(false);
}
};
const onPointerDownOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || cardRef.current?.contains(target)) return;
setOpen(false);
setTouchMode(false);
};
window.addEventListener("scroll", reposition, { passive: true, capture: true });
window.addEventListener("resize", reposition);
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointerDownOutside);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", reposition, { capture: true });
window.removeEventListener("resize", reposition);
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointerDownOutside);
};
}, [open, position]);
React.useEffect(() => clearTimers, []);
const isMouse = (event: React.PointerEvent) => event.pointerType === "mouse";
return (
<span className="relative inline" style={{ ["--lp-accent" as string]: accent }}>
<a
ref={anchorRef}
href={href}
aria-describedby={descId}
className={`rounded-[3px] font-medium text-inherit underline decoration-[color-mix(in_oklab,var(--lp-accent)_70%,transparent)] decoration-1 underline-offset-[3px] transition-[text-decoration-color,text-decoration-thickness] duration-150 hover:decoration-[var(--lp-accent)] hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--lp-accent)] ${open ? "decoration-[var(--lp-accent)] decoration-2" : ""} ${className}`}
style={style}
onPointerDown={(event) => {
onPointerDown?.(event);
pointerType.current = event.pointerType;
}}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (!isMouse(event)) return;
pointerType.current = "mouse";
pointerY.current = event.clientY;
scheduleOpen(openDelay);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
if (!isMouse(event)) return;
pointerY.current = event.clientY;
if (!followCursor || reduceMotion || !open) return;
const { cx, min, max } = geometry.current;
followRaw.set(Math.min(max, Math.max(min, (event.clientX - cx) * FOLLOW)));
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
if (!isMouse(event)) return;
scheduleClose(closeDelay);
}}
onFocus={(event) => {
onFocus?.(event);
if (event.currentTarget.matches(":focus-visible")) {
pointerY.current = null;
scheduleOpen(openDelay);
}
}}
onBlur={(event) => {
onBlur?.(event);
if (cardRef.current?.contains(event.relatedTarget as Node | null)) return;
scheduleClose(touchMode ? closeDelay : 0);
}}
onClick={(event) => {
// Touch: the first tap previews, the second follows the link.
if (pointerType.current !== "mouse" && event.detail > 0 && !(open && touchMode)) {
event.preventDefault();
clearTimers();
pointerY.current = null;
setTouchMode(true);
setOpen(true);
}
onClick?.(event);
}}
{...props}
>
{children}
</a>
{/* Accessible description; hidden elements still contribute to aria-describedby. */}
<span id={descId} hidden>
{[preview.title, preview.description].filter(Boolean).join(". ")}
</span>
<AnimatePresence
onExitComplete={() => {
followRaw.jump(0);
followX.jump(0);
}}
>
{open && (
<motion.span
key="card"
ref={mountCard}
popover="manual"
aria-hidden={touchMode ? undefined : true}
onPointerEnter={(event) => {
if (isMouse(event)) clearTimeout(closeTimer.current);
}}
onPointerLeave={(event) => {
if (isMouse(event)) scheduleClose(closeDelay);
}}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (!event.currentTarget.contains(next) && next !== anchorRef.current) scheduleClose(closeDelay);
}}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.86 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94, transition: { duration: 0.12 } }}
transition={reduceMotion ? { duration: 0.01 } : { type: "spring", stiffness: 420, damping: 30, mass: 0.8 }}
className="fixed inset-auto z-50 m-0 block overflow-visible border-0 bg-transparent p-0 text-left text-zinc-900 dark:text-zinc-100"
style={{ x: followX, width: `min(${width}px, calc(100vw - ${EDGE * 2}px))` }}
>
<span className="block overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_24px_48px_-24px_rgb(0_0_0/0.35),0_2px_6px_-2px_rgb(0_0_0/0.08)] dark:border-white/10 dark:bg-[#1a1d21] dark:shadow-[0_24px_48px_-20px_rgb(0_0_0/0.7)]">
{preview.image ? (
<span className="block aspect-[16/9] w-full overflow-hidden border-b border-zinc-900/10 bg-zinc-100 dark:border-white/10 dark:bg-zinc-800">
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img src={preview.image} alt={preview.imageAlt ?? ""} draggable={false} className="block size-full object-cover" />
</span>
) : null}
<span className="block p-3.5">
<span className="block truncate font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{preview.meta ?? host}
</span>
<span className="mt-1 block text-[15px] font-medium leading-snug tracking-tight">{preview.title}</span>
{preview.description ? (
<span className="mt-1 line-clamp-3 block text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-400">{preview.description}</span>
) : null}
{touchMode ? (
<a
href={href}
target={props.target}
rel={props.rel}
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-full bg-[var(--lp-accent)] px-3.5 text-[13px] font-medium text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--lp-accent)]"
>
{openLabel}
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3">
<path d="M5 11l6-6M6 5h5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
) : (
<span className="mt-3 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3">
<path d="M5 11l6-6M6 5h5v5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{host}
</span>
)}
</span>
</span>
</motion.span>
)}
</AnimatePresence>
</span>
);
}