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.
*
* Positioning is `fixed`; avoid ancestors with transforms/filters, which
* re-scope fixed positioning.
*/
export function HoverPreview({
preview,
previewClassName,
openDelay = 200,
static: isStatic = false,
className,
children,
...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);
};
}, []);
const track = (event: React.PointerEvent) => {
const width = cardRef.current?.offsetWidth ?? 288;
const height = cardRef.current?.offsetHeight ?? 120;
x.set(Math.min(event.clientX + 14, window.innerWidth - width - 12));
y.set(Math.min(event.clientY + 18, window.innerHeight - height - 12));
};
const cancelOpen = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
return (
<span
data-slot="hover-preview"
className={cn("relative inline-block", className)}
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) => {
if (event.pointerType !== "mouse") return;
track(event);
}}
onPointerLeave={() => {
cancelOpen();
setMode(null);
}}
onFocus={(event) => {
if (event.target.matches(":focus-visible")) setMode("anchor");
}}
onBlur={() => {
cancelOpen();
setMode(null);
}}
{...props}
>
{children}
<AnimatePresence>
{mode && (
<motion.div
ref={cardRef}
role="tooltip"
className={cn(
"z-50 w-72",
mode === "cursor"
? "pointer-events-none fixed top-0 left-0"
: "pointer-events-none 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)",
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>
);
}