Inline link preview card that trails the cursor on springs after an intent delay, anchoring below the trigger for keyboard focus.
npx shadcn@latest add @paragon/hover-preview"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
} from "motion/react";
import { cn } from "@/lib/utils";
export interface HoverPreviewProps extends React.ComponentProps<"span"> {
/** Preview card content. */
preview: React.ReactNode;
/** Styles the preview card surface. */
previewClassName?: string;
/** ms of intent delay before the preview opens on hover. */
openDelay?: number;
/** Pins the preview below the trigger instead of following the cursor. */
static?: boolean;
}
/**
* Wraps an inline trigger (usually a link) and reveals a floating preview
* card that trails the cursor on springs — decorative lag that makes the
* card feel attached rather than glued. Opens after a 200ms intent delay,
* mouse pointers only. Keyboard focus (and reduced motion) shows the same
* card anchored below the trigger instead. The card is pointer-events-none,
* so it never traps the cursor.
*
* The card is positioned `absolute` within the trigger's own box, so it
* trails the cursor while staying anchored near the trigger — unaffected by
* ancestor transforms/filters or narrow containers.
*/
export function HoverPreview({
preview,
previewClassName,
openDelay = 200,
static: isStatic = false,
className,
children,
onPointerEnter,
onPointerMove,
onPointerLeave,
onFocus,
onBlur,
...props
}: HoverPreviewProps) {
const reducedMotion = useReducedMotion();
const anchorOnly = isStatic || !!reducedMotion;
const [mode, setMode] = React.useState<"cursor" | "anchor" | null>(null);
const cardRef = React.useRef<HTMLDivElement>(null);
const timer = React.useRef<ReturnType<typeof setTimeout>>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const springX = useSpring(x, { stiffness: 300, damping: 30 });
const springY = useSpring(y, { stiffness: 300, damping: 30 });
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
// WCAG 1.4.13: hover-revealed content must be dismissable without
// moving the pointer — Esc closes the preview from anywhere.
React.useEffect(() => {
if (!mode) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setMode(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [mode]);
// The card is positioned `absolute` inside the trigger's own box, so we
// track the pointer RELATIVE to the trigger (not the viewport). This keeps
// the card pinned a few px off the cursor and immune to ancestor
// transforms/filters — which re-scope `fixed` — and to narrow containers,
// where a viewport clamp would otherwise fling the card to the far right.
const trackRef = React.useRef<HTMLSpanElement>(null);
const track = (event: React.PointerEvent) => {
const host = trackRef.current;
if (!host) return;
const rect = host.getBoundingClientRect();
const width = cardRef.current?.offsetWidth ?? 288;
// Local x within the trigger, plus a small lead so the card trails the
// cursor. Clamp the right edge to the trigger's positioning context
// (offsetParent) so a wide card never spills past its container.
const localX = event.clientX - rect.left + 14;
const parentWidth =
host.offsetParent instanceof HTMLElement
? host.offsetParent.clientWidth
: window.innerWidth;
const maxX = parentWidth - host.offsetLeft - width - 8;
x.set(Math.min(localX, Math.max(0, maxX)));
y.set(event.clientY - rect.top + 18);
};
const cancelOpen = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
return (
<span
ref={trackRef}
data-slot="hover-preview"
className={cn("relative inline-block", className)}
// Consumer handlers are composed, never clobbered by the spread.
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (event.pointerType !== "mouse") return;
track(event);
cancelOpen();
timer.current = setTimeout(() => {
if (anchorOnly) {
setMode("anchor");
} else {
// Land where the cursor already is instead of flying across.
springX.jump(x.get());
springY.jump(y.get());
setMode("cursor");
}
}, openDelay);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
if (event.pointerType !== "mouse") return;
track(event);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
cancelOpen();
setMode(null);
}}
onFocus={(event) => {
onFocus?.(event);
if (event.target.matches(":focus-visible")) setMode("anchor");
}}
onBlur={(event) => {
onBlur?.(event);
cancelOpen();
setMode(null);
}}
{...props}
>
{children}
<AnimatePresence>
{mode && (
<motion.div
ref={cardRef}
role="tooltip"
className={cn(
"pointer-events-none z-50 w-72",
// Both modes anchor to the trigger's own box (the span is
// `relative`), so the card stays near the cursor/trigger and is
// unaffected by ancestor transforms or a narrow viewport.
mode === "cursor"
? "absolute top-0 left-0"
: "absolute top-full left-0 mt-2",
)}
style={mode === "cursor" ? { x: springX, y: springY } : undefined}
initial={{
opacity: 0,
scale: anchorOnly ? 1 : 0.97,
filter: "blur(4px)",
}}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{
opacity: 0,
scale: anchorOnly ? 1 : 0.99,
filter: "blur(4px)",
// --ease-exit as a bezier array (motion can't read CSS vars);
// ~half the enter, settling to 0.99 — never retracing it.
transition: { duration: 0.12, ease: [0.4, 0, 1, 1] },
}}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<div
className={cn(
"rounded-xl bg-popover p-3 text-popover-foreground shadow-overlay",
previewClassName,
)}
>
{preview}
</div>
</motion.div>
)}
</AnimatePresence>
</span>
);
}