Hover Preview
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.
 *
 * 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>
  );
}