# Hover Card Link Preview — React Tooltip Component

> Inline links that open a hover card with a drawn miniature of the destination page, which slowly scrolls while you read the summary.

- Category: [Tooltips & Popovers](https://pixelpapa.com/components/tooltips.md)
- Tags: hover-card, link, preview, popover, inline
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/tooltips/hover-card-link-preview

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/hover-card-link-preview.json"
```

The file is written to `components/pixelpapa/hover-card-link-preview.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowUpRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";

type LinkPreviewProps = {
  href: string;
  title: string;
  description: string;
  /** Any CSS colour; tints the drawn page. */
  accent?: string;
  defaultOpen?: boolean;
  children: ReactNode;
  className?: string;
};

/** A tiny fake webpage: browser chrome plus a long page that pans while the card is open. */
function PagePreview({ domain, accent }: { domain: string; accent: string }) {
  const reduce = useReducedMotion();
  const line = "block h-1.5 rounded-full bg-foreground/15";
  return (
    <span className="block overflow-hidden rounded-lg border border-border bg-background">
      <span className="flex items-center gap-1 border-b border-border bg-muted px-2 py-1.5">
        {[0, 1, 2].map((i) => (
          <span key={i} className="size-1.5 rounded-full bg-foreground/20" />
        ))}
        <span className="ml-1.5 flex-1 truncate rounded-full bg-background px-2 py-0.5 text-[9px] text-muted-foreground">{domain}</span>
      </span>
      <span className="block h-28 overflow-hidden">
        <motion.span
          className="block p-3"
          animate={reduce ? undefined : { y: [0, -92, 0] }}
          transition={{ duration: 9, ease: "easeInOut", repeat: Infinity, delay: 0.6 }}
        >
          <span className="flex items-center justify-between">
            <span className="size-3 rounded" style={{ background: accent }} />
            <span className="flex gap-1.5">
              <span className={cn(line, "w-5")} />
              <span className={cn(line, "w-5")} />
              <span className="block h-1.5 w-6 rounded-full" style={{ background: accent }} />
            </span>
          </span>
          <span
            className="mt-3 block rounded-md p-3"
            style={{ background: `linear-gradient(135deg, color-mix(in oklch, ${accent} 30%, transparent), color-mix(in oklch, ${accent} 8%, transparent))` }}
          >
            <span className="block h-2.5 w-3/5 rounded-full bg-foreground/60" />
            <span className="mt-1.5 block h-2.5 w-2/5 rounded-full bg-foreground/60" />
            <span className={cn(line, "mt-2.5 w-4/5")} />
            <span className="mt-2.5 block h-3 w-10 rounded-full" style={{ background: accent }} />
          </span>
          <span className="mt-3 grid grid-cols-3 gap-2">
            {[0, 1, 2].map((i) => (
              <span key={i} className="block rounded-md border border-border p-2">
                <span className="block size-2.5 rounded" style={{ background: accent, opacity: 0.7 }} />
                <span className={cn(line, "mt-2 w-full")} />
                <span className={cn(line, "mt-1 w-2/3")} />
              </span>
            ))}
          </span>
          <span className={cn(line, "mt-3 w-full")} />
          <span className={cn(line, "mt-1.5 w-11/12")} />
          <span className={cn(line, "mt-1.5 w-3/4")} />
        </motion.span>
      </span>
    </span>
  );
}

export function LinkPreview({ href, title, description, accent = "var(--primary)", defaultOpen = false, children, className }: LinkPreviewProps) {
  const id = useId();
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const [open, setOpen] = useState(defaultOpen);
  const domain = href.replace(/^https?:\/\//, "").replace(/\/$/, "");

  useEffect(() => () => clearTimeout(timer.current), []);

  // Small delays give hover intent: skimming past does not flash a card, and the gap to the card is forgiving.
  function schedule(next: boolean) {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setOpen(next), next ? 140 : 160);
  }

  return (
    <span
      className="relative inline-block"
      onPointerEnter={() => schedule(true)}
      onPointerLeave={() => schedule(false)}
      onFocus={() => schedule(true)}
      onBlur={() => schedule(false)}
      onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
    >
      <a
        href={href}
        aria-describedby={open ? id : undefined}
        className={cn(
          "rounded-sm font-medium text-foreground underline decoration-primary/40 decoration-2 underline-offset-4 transition-colors hover:decoration-primary",
          className,
        )}
      >
        {children}
      </a>
      <AnimatePresence>
        {open && (
          <motion.span
            id={id}
            role="tooltip"
            initial={{ opacity: 0, y: 10, scale: 0.9, rotateX: -12 }}
            animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
            exit={{ opacity: 0, y: 6, scale: 0.95, transition: { duration: 0.12 } }}
            transition={{ type: "spring", stiffness: 380, damping: 26 }}
            style={{ transformOrigin: "50% 100%", transformPerspective: 600 }}
            className="absolute bottom-full left-1/2 z-30 -ml-36 block w-72 pb-2.5 text-left"
          >
            <span className="block rounded-2xl border border-border bg-popover p-2.5 text-popover-foreground shadow-xl shadow-black/10">
              <PagePreview domain={domain} accent={accent} />
              <span className="mt-2.5 flex items-start justify-between gap-3 px-1 pb-0.5">
                <span className="block min-w-0">
                  <span className="block truncate text-sm leading-5 font-semibold">{title}</span>
                  <span className="mt-0.5 line-clamp-2 text-xs leading-[1.45] font-normal text-muted-foreground">{description}</span>
                </span>
                <ArrowUpRight className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
              </span>
            </span>
          </motion.span>
        )}
      </AnimatePresence>
    </span>
  );
}

export default function HoverCardLinkPreviewDemo() {
  return (
    <div className="flex h-[400px] w-full max-w-lg flex-col justify-end">
      <p className="text-lg leading-9 text-pretty text-muted-foreground">
        We rebuilt the editor on{" "}
        <LinkPreview
          href="https://loomwork.dev/sync"
          title="Loomwork Sync"
          description="A conflict-free sync engine for collaborative apps, with offline support built in."
          defaultOpen
        >
          Loomwork Sync
        </LinkPreview>{" "}
        this spring, moved search to{" "}
        <LinkPreview
          href="https://quarry.search/docs"
          title="Quarry — search that ships"
          description="Typo-tolerant full-text search you can host on a single small machine."
          accent="var(--chart-4)"
        >
          Quarry
        </LinkPreview>{" "}
        and wrote up what we learned in the{" "}
        <LinkPreview
          href="https://northwind.app/blog/local-first"
          title="Going local-first in 90 days"
          description="What broke, what got faster, and the migration plan we would follow again."
          accent="var(--chart-5)"
        >
          engineering blog
        </LinkPreview>
        .
      </p>
    </div>
  );
}
```

## AI prompt

```text
Add the "Hover Card Link Preview" component from PixelPapa UI to this project.

What it is: Inline links that open a hover card with a drawn miniature of the destination page, which slowly scrolls while you read the summary.
Category: Tooltips & Popovers. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/hover-card-link-preview.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/hover-card-link-preview.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/hover-card-link-preview" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More tooltips & popovers

- [Popover Color Picker Mini](https://pixelpapa.com/components/tooltips/popover-color-picker-mini.md) (Pro): A compact colour popover with a draggable saturation field, hue slider, swatches and hex input that springs out of its swatch trigger.
- [Popover Share Menu](https://pixelpapa.com/components/tooltips/popover-share-menu.md) (Pro): A share popover with a public-link switch, a copy button that flips to a confirmed state with a sweep across the URL, and quick share targets.
- [Tooltip Rich Card](https://pixelpapa.com/components/tooltips/tooltip-rich-card.md): A toolbar whose rich tooltip (title, body, shortcut keys) glides from button to button instead of closing and reopening.
- [Tooltip Spring Follow](https://pixelpapa.com/components/tooltips/tooltip-spring-follow.md): A tooltip that trails the cursor on a spring and tilts with its velocity, like a tag swinging from the pointer.
