Overlays

Hover Preview

Inline link preview card that trails the cursor on springs after an intent delay, anchoring below the trigger for keyboard focus.

Install

npx shadcn@latest add @paragon/hover-preview

hover-preview.tsx

"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>
  );
}