{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "link-preview",
  "title": "Link Preview",
  "description": "An inline link that shows where it leads before you follow it: an image, the title, a description, and the domain.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/link-preview/link-preview.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type LinkPreviewData = {\n  title: string\n  description?: string\n  /** An image or screenshot of the page. */\n  image?: string\n  /** Defaults to the link's host name. */\n  domain?: string\n}\n\nexport interface LinkPreviewProps extends Omit<\n  React.AnchorHTMLAttributes<HTMLAnchorElement>,\n  \"href\"\n> {\n  href: string\n  /** Preview data the host already has. */\n  preview?: LinkPreviewData\n  /** Fetches the preview on first hover or focus. Results are kept per href. */\n  loadPreview?: (href: string) => Promise<LinkPreviewData>\n  /** How long a pointer or focus rests on the link before the card opens. */\n  openDelay?: number\n  /** How long the card waits for the pointer to come back. */\n  closeDelay?: number\n  loadingLabel?: string\n  unavailableLabel?: string\n}\n\ntype Loaded =\n  | { href: string; status: \"loading\" }\n  | { href: string; status: \"ready\"; data: LinkPreviewData }\n  | { href: string; status: \"error\" }\n\nconst cache = new Map<string, Promise<LinkPreviewData>>()\n\nfunction load(\n  href: string,\n  loader: (href: string) => Promise<LinkPreviewData>\n) {\n  let pending = cache.get(href)\n  if (!pending) {\n    pending = loader(href)\n    cache.set(href, pending)\n    pending.catch(() => cache.delete(href))\n  }\n  return pending\n}\n\nfunction hostOf(href: string) {\n  try {\n    return new URL(href, \"http://localhost\").hostname.replace(/^www\\./, \"\")\n  } catch {\n    return href\n  }\n}\n\nconst EDGE = 8\nconst POINTER_FOCUS_GRACE = 800\n\n/**\n * An inline link that shows where it leads before you follow it. The card is\n * a description of the link, never a replacement for it: the link keeps its\n * own click, its own focus, and its own behavior on touch.\n */\nexport function LinkPreview({\n  href,\n  preview,\n  loadPreview,\n  openDelay = 500,\n  closeDelay = 250,\n  loadingLabel = \"Loading preview\",\n  unavailableLabel = \"No preview available\",\n  className,\n  children,\n  onPointerEnter,\n  onPointerLeave,\n  onPointerDown,\n  onFocus,\n  onBlur,\n  ...anchorProps\n}: LinkPreviewProps) {\n  const id = React.useId()\n  const cardId = `${id}-preview`\n  const linkRef = React.useRef<HTMLAnchorElement>(null)\n  const cardRef = React.useRef<HTMLDivElement>(null)\n  const openTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const pressedAt = React.useRef(0)\n  const [open, setOpen] = React.useState(false)\n  const [loaded, setLoaded] = React.useState<Loaded | null>(null)\n  const [brokenImage, setBrokenImage] = React.useState<string | null>(null)\n\n  const enabled = Boolean(preview || loadPreview)\n  const current = loaded?.href === href ? loaded : null\n  const data = preview ?? (current?.status === \"ready\" ? current.data : null)\n  const status = preview ? \"ready\" : (current?.status ?? \"loading\")\n  const domain = data?.domain ?? hostOf(href)\n\n  const clearTimers = () => {\n    if (openTimer.current) clearTimeout(openTimer.current)\n    if (closeTimer.current) clearTimeout(closeTimer.current)\n    openTimer.current = null\n    closeTimer.current = null\n  }\n\n  React.useEffect(() => clearTimers, [])\n\n  const fetchPreview = () => {\n    if (preview || !loadPreview) return\n    if (current && current.status !== \"error\") return\n    setLoaded({ href, status: \"loading\" })\n    load(href, loadPreview).then(\n      (result) => setLoaded({ href, status: \"ready\", data: result }),\n      () => setLoaded({ href, status: \"error\" })\n    )\n  }\n\n  const scheduleOpen = () => {\n    if (!enabled) return\n    fetchPreview()\n    if (closeTimer.current) clearTimeout(closeTimer.current)\n    closeTimer.current = null\n    if (open || openTimer.current) return\n    openTimer.current = setTimeout(() => {\n      openTimer.current = null\n      setOpen(true)\n    }, openDelay)\n  }\n\n  const scheduleClose = () => {\n    if (openTimer.current) clearTimeout(openTimer.current)\n    openTimer.current = null\n    if (!open || closeTimer.current) return\n    closeTimer.current = setTimeout(() => {\n      closeTimer.current = null\n      setOpen(false)\n    }, closeDelay)\n  }\n\n  const close = () => {\n    clearTimers()\n    setOpen(false)\n  }\n\n  const place = React.useCallback(() => {\n    const link = linkRef.current\n    const card = cardRef.current\n    if (!link || !card) return\n    const rects = link.getClientRects()\n    const rect = rects[0] ?? link.getBoundingClientRect()\n    const last = rects[rects.length - 1] ?? rect\n    const width = card.offsetWidth\n    const height = card.offsetHeight\n    const below =\n      window.innerHeight - last.bottom >= height || rect.top < height\n    const left = Math.min(\n      Math.max(rect.left, EDGE),\n      Math.max(EDGE, document.documentElement.clientWidth - width - EDGE)\n    )\n    card.dataset.side = below ? \"bottom\" : \"top\"\n    card.style.left = `${left}px`\n    card.style.top = `${below ? last.bottom : rect.top - height}px`\n  }, [])\n\n  React.useLayoutEffect(() => {\n    if (!open) return\n    place()\n    let frame = 0\n    const follow = () => {\n      cancelAnimationFrame(frame)\n      frame = requestAnimationFrame(place)\n    }\n    const dismiss = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        clearTimers()\n        setOpen(false)\n      }\n    }\n    window.addEventListener(\"scroll\", follow, true)\n    window.addEventListener(\"resize\", follow)\n    document.addEventListener(\"keydown\", dismiss)\n    return () => {\n      cancelAnimationFrame(frame)\n      window.removeEventListener(\"scroll\", follow, true)\n      window.removeEventListener(\"resize\", follow)\n      document.removeEventListener(\"keydown\", dismiss)\n    }\n  }, [open, place, status])\n\n  const image = data?.image && data.image !== brokenImage ? data.image : null\n\n  return (\n    <>\n      <a\n        ref={linkRef}\n        href={href}\n        aria-describedby={open ? cardId : undefined}\n        data-slot=\"link-preview\"\n        data-state={open ? \"open\" : \"closed\"}\n        className={cn(\n          \"text-primary decoration-primary/40 hover:decoration-primary focus-visible:ring-ring rounded-sm underline underline-offset-4 transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\",\n          className\n        )}\n        onPointerEnter={(event) => {\n          onPointerEnter?.(event)\n          if (event.pointerType !== \"touch\") scheduleOpen()\n        }}\n        onPointerLeave={(event) => {\n          onPointerLeave?.(event)\n          if (event.pointerType !== \"touch\") scheduleClose()\n        }}\n        onPointerDown={(event) => {\n          onPointerDown?.(event)\n          pressedAt.current = Date.now()\n          if (event.pointerType === \"touch\") close()\n        }}\n        onFocus={(event) => {\n          onFocus?.(event)\n          // A press focuses the link on its way to following it. Hover has\n          // already answered a mouse, and a tap is not a request to preview.\n          if (Date.now() - pressedAt.current < POINTER_FOCUS_GRACE) return\n          scheduleOpen()\n        }}\n        onBlur={(event) => {\n          onBlur?.(event)\n          close()\n        }}\n        {...anchorProps}\n      >\n        {children}\n      </a>\n      {open\n        ? createPortal(\n            <div\n              ref={cardRef}\n              id={cardId}\n              role=\"tooltip\"\n              data-slot=\"link-preview-card\"\n              data-status={status}\n              className=\"fixed z-50 w-80 max-w-[calc(100vw-1rem)] transition-[opacity,translate] duration-150 ease-out data-[side=bottom]:pt-2 data-[side=top]:pb-2 motion-reduce:transition-none starting:translate-y-1 starting:opacity-0\"\n              style={{ top: 0, left: 0 }}\n              onPointerEnter={() => {\n                if (closeTimer.current) clearTimeout(closeTimer.current)\n                closeTimer.current = null\n              }}\n              onPointerLeave={scheduleClose}\n            >\n              <div className=\"bg-popover text-popover-foreground border-border overflow-hidden rounded-[var(--radius)] border shadow-lg\">\n                {status === \"loading\" ? (\n                  <div aria-busy=\"true\" className=\"grid gap-2 p-3\">\n                    <span className=\"sr-only\">{loadingLabel}</span>\n                    <div className=\"bg-muted aspect-[1.91/1] w-full animate-pulse rounded-md motion-reduce:animate-none\" />\n                    <div className=\"bg-muted h-4 w-3/4 animate-pulse rounded motion-reduce:animate-none\" />\n                    <div className=\"bg-muted h-3 w-full animate-pulse rounded motion-reduce:animate-none\" />\n                    <div className=\"bg-muted h-3 w-1/3 animate-pulse rounded motion-reduce:animate-none\" />\n                  </div>\n                ) : data ? (\n                  <>\n                    {image ? (\n                      // eslint-disable-next-line @next/next/no-img-element\n                      <img\n                        src={image}\n                        alt=\"\"\n                        className=\"bg-muted aspect-[1.91/1] w-full object-cover\"\n                        onLoad={place}\n                        onError={() => setBrokenImage(image)}\n                      />\n                    ) : null}\n                    <div className=\"grid gap-1 p-3\">\n                      <p className=\"line-clamp-2 text-sm font-semibold\">\n                        {data.title}\n                      </p>\n                      {data.description ? (\n                        <p className=\"text-muted-foreground line-clamp-3 text-xs leading-relaxed\">\n                          {data.description}\n                        </p>\n                      ) : null}\n                      <p className=\"text-muted-foreground truncate text-xs font-medium\">\n                        {domain}\n                      </p>\n                    </div>\n                  </>\n                ) : (\n                  <div className=\"grid gap-1 p-3\">\n                    <p className=\"text-sm\">{unavailableLabel}</p>\n                    <p className=\"text-muted-foreground truncate text-xs font-medium\">\n                      {domain}\n                    </p>\n                  </div>\n                )}\n              </div>\n            </div>,\n            document.body\n          )\n        : null}\n    </>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "links",
    "preview",
    "hover",
    "navigation"
  ],
  "type": "registry:ui"
}