{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "link-preview",
  "type": "registry:ui",
  "title": "Link Preview",
  "description": "Unfurled link card and inline hover preview sharing one cached request.",
  "categories": [
    "content"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/link-preview.tsx",
      "content": "\"use client\";\nimport { PreviewCard } from \"@base-ui/react/preview-card\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { isValidElement, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { ArrowUpRight, Refresh, X } from \"@/lib/icons\";\n\n/* -------------------------------------------------------------------------------------------------\n * Data\n * -----------------------------------------------------------------------------------------------*/\n\nexport type LinkMeta = {\n  url: string;\n  title?: string;\n  description?: string;\n  /** The page's social image. Shown at 1.91:1 in the card, square in the row. */\n  image?: string;\n  /** Alt text for the image, when the page gives one. */\n  imageAlt?: string;\n  /** A URL, or your own node (an inline SVG, a brand mark). Falls back to a letter tile. */\n  favicon?: string | React.ReactNode;\n  siteName?: string;\n  /** One short line of context, e.g. \"8 min read\" or \"Updated 3 days ago\". */\n  meta?: string;\n};\n\nexport type LinkPreviewLoad = (url: string) => Promise<LinkMeta>;\ntype Status = \"loading\" | \"ready\" | \"error\";\ntype Entry = { status: Status; data?: LinkMeta };\n\n// One cache for every preview on the page: the inline hover and the unfurled card for the same\n// URL share a request, and a preview that has loaded once never shows a skeleton again.\nconst entries = new Map<string, Entry>();\nconst listeners = new Set<() => void>();\nconst emit = () => listeners.forEach((l) => l());\nconst subscribe = (l: () => void) => {\n  listeners.add(l);\n  return () => void listeners.delete(l);\n};\n\n/** Starts loading a URL's preview, unless it's already loading or loaded. Safe to call on hover. */\nexport function prefetchLinkPreview(url: string, load: LinkPreviewLoad) {\n  const current = entries.get(url);\n  if (current && current.status !== \"error\") return;\n  entries.set(url, { status: \"loading\" });\n  emit();\n  Promise.resolve()\n    .then(() => load(url))\n    .then(\n      (data) => entries.set(url, { status: \"ready\", data: { ...data, url: data.url || url } }),\n      () => entries.set(url, { status: \"error\" }),\n    )\n    .then(emit);\n}\n\n/** Forgets cached previews, all of them or one URL's. */\nexport function clearLinkPreviews(url?: string) {\n  if (url) entries.delete(url);\n  else entries.clear();\n  emit();\n}\n\nexport type LinkPreviewState = { status: Status; data: LinkMeta | undefined; retry: () => void };\n\n/**\n * The load state of one URL's preview. Pass `data` when you already have it (server-side\n * unfurls), or `load` to fetch it here. With neither, it's built from the URL alone.\n */\nexport function useLinkPreview(url: string, { data, load, enabled = true }: { data?: LinkMeta; load?: LinkPreviewLoad; enabled?: boolean } = {}): LinkPreviewState {\n  const entry = useSyncExternalStore(subscribe, () => entries.get(url), () => undefined);\n  const loadRef = useRef(load);\n  useEffect(() => {\n    loadRef.current = load;\n  });\n  const needed = !data && !!load && enabled;\n  useEffect(() => {\n    if (needed && loadRef.current) prefetchLinkPreview(url, loadRef.current);\n  }, [url, needed]);\n\n  const retry = () => loadRef.current && prefetchLinkPreview(url, loadRef.current);\n  if (data) return { status: \"ready\", data, retry };\n  if (!load) return { status: \"ready\", data: { url }, retry };\n  return { status: entry?.status ?? \"loading\", data: entry?.data, retry };\n}\n\nfunction parse(url: string) {\n  try {\n    const u = new URL(url);\n    const host = u.hostname.replace(/^www\\./, \"\");\n    const path = (u.pathname + u.search).replace(/\\/$/, \"\");\n    return { host, path, label: host + path };\n  } catch {\n    return { host: url, path: \"\", label: url };\n  }\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Small parts\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Favicon({ src, host, size = 16 }: { src?: LinkMeta[\"favicon\"]; host: string; size?: number }) {\n  const [failed, setFailed] = useState<string | null>(null);\n  const box = cn(\"grid shrink-0 place-items-center overflow-hidden\", size > 20 ? \"rounded-lg\" : \"rounded-[4px]\");\n  if (isValidElement(src))\n    return (\n      <span aria-hidden className={cn(box, \"text-fg\")} style={{ width: size, height: size }}>\n        {src}\n      </span>\n    );\n  if (typeof src === \"string\" && src && failed !== src)\n    return (\n      // eslint-disable-next-line @next/next/no-img-element -- favicons come from anywhere; next/image can't assume the host\n      <img src={src} alt=\"\" width={size} height={size} onError={() => setFailed(src)} className={box} style={{ width: size, height: size }} />\n    );\n  // No icon, or it failed: the site's first letter on a quiet tile, the same size as the icon would be.\n  return (\n    <span\n      aria-hidden\n      className={cn(box, \"border border-line-2 bg-hover font-medium uppercase leading-none text-fg-2\")}\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.58) }}\n    >\n      {host.charAt(0)}\n    </span>\n  );\n}\n\n// The image slot: reserved at its final size before anything loads. The picture settles in\n// from a 4% zoom as it decodes; if there isn't one, or it fails, the site's mark stands in.\nfunction Media({ src, alt, favicon, host, className }: { src?: string; alt?: string; favicon?: LinkMeta[\"favicon\"]; host: string; className?: string }) {\n  const [state, setState] = useState<{ src?: string; status: \"loading\" | \"loaded\" | \"failed\" }>({ src, status: \"loading\" });\n  const status = state.src === src ? state.status : \"loading\";\n  const set = (s: \"loaded\" | \"failed\") => setState({ src, status: s });\n  const fallback = !src || status === \"failed\";\n  return (\n    <div className={cn(\"relative overflow-hidden bg-frame\", className)}>\n      <div\n        aria-hidden\n        className={cn(\"absolute inset-0 grid place-items-center transition-opacity duration-200\", fallback ? \"opacity-100\" : \"opacity-0\")}\n      >\n        <span className=\"grid size-10 place-items-center rounded-xl border border-line bg-raised shadow-[var(--shadow)]\">\n          <Favicon src={favicon} host={host} size={20} />\n        </span>\n      </div>\n      {src && status !== \"failed\" && (\n        // eslint-disable-next-line @next/next/no-img-element -- remote OG images; next/image can't assume the host\n        <img\n          key={src}\n          src={src}\n          alt={alt ?? \"\"}\n          loading=\"lazy\"\n          decoding=\"async\"\n          ref={(el) => {\n            // Already decoded (cached) before hydration: skip the fade.\n            if (el?.complete && el.naturalWidth && status === \"loading\") set(\"loaded\");\n          }}\n          onLoad={() => set(\"loaded\")}\n          onError={() => set(\"failed\")}\n          className={cn(\n            \"absolute inset-0 size-full object-cover transition-[opacity,scale] duration-[420ms] ease-out-expo\",\n            status === \"loaded\" ? \"scale-100 opacity-100\" : \"scale-[1.04] opacity-0\",\n            \"motion-reduce:scale-100\",\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\nconst Bone = ({ className }: { className?: string }) => (\n  <span aria-hidden className={cn(\"block animate-pulse-soft rounded-[4px] bg-fg/[0.07] motion-reduce:animate-none\", className)} />\n);\n\n/* -------------------------------------------------------------------------------------------------\n * Body: the content of a preview, shared by the card and the hover popup\n * -----------------------------------------------------------------------------------------------*/\n\ntype Layout = \"card\" | \"row\";\n\nfunction Body({\n  url,\n  state,\n  layout,\n  openInNewTab,\n  action,\n}: {\n  url: string;\n  state: LinkPreviewState;\n  layout: Layout;\n  openInNewTab: boolean;\n  /** Sits at the end of the site line in place of the arrow (the row's remove button). */\n  action?: React.ReactNode;\n}) {\n  const { status, data, retry } = state;\n  const { host, label } = parse(data?.url ?? url);\n  const loading = status === \"loading\" && !data;\n  const failed = status === \"error\" && !data;\n  const title = data?.title || label;\n  const site = data?.siteName || host;\n  const reduce = useReducedMotion();\n\n  const link = (\n    <a\n      href={data?.url ?? url}\n      target={openInNewTab ? \"_blank\" : undefined}\n      rel={openInNewTab ? \"noopener noreferrer\" : undefined}\n      // The whole card is the target; its focus ring is drawn by the card (has-focus-visible).\n      className=\"outline-none after:absolute after:inset-0 after:rounded-[inherit] after:content-['']\"\n    >\n      {title}\n    </a>\n  );\n\n  const siteRow = (\n    <p className=\"flex min-w-0 items-center gap-1.5 text-[12px] leading-4 text-fg-3\">\n      <Favicon src={data?.favicon} host={host} size={14} />\n      <span className=\"truncate\">{site}</span>\n      {data?.meta && !failed && (\n        <>\n          <span aria-hidden className=\"text-fg-4\">\n            ·\n          </span>\n          <span className=\"shrink-0\">{data.meta}</span>\n        </>\n      )}\n      {action ?? (\n        <ArrowUpRight\n          size={14}\n          className=\"-my-px ml-auto shrink-0 text-fg-4 transition-[translate,color] duration-200 ease-out-expo group-hover/lp:-translate-y-px group-hover/lp:translate-x-px group-hover/lp:text-fg-2\"\n        />\n      )}\n    </p>\n  );\n\n  const failure = (\n    <div role=\"alert\" className=\"relative z-10 flex items-center justify-between gap-3\">\n      <p className=\"text-[12.5px] leading-[18px] text-fg-3\">Couldn’t load the preview</p>\n      <button\n        type=\"button\"\n        onClick={retry}\n        className={cn(\n          \"-my-1 -mr-1.5 inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none\",\n          \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        )}\n      >\n        <Refresh size={14} className=\"text-fg-3\" />\n        Try again\n      </button>\n    </div>\n  );\n\n  const text = (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.div\n        key={loading ? \"skeleton\" : \"content\"}\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0 }}\n        transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n        className=\"flex min-w-0 flex-col gap-1.5\"\n      >\n        {loading ? (\n          // Held back 150ms, so a fast unfurl goes straight to content without a flash of gray.\n          <div aria-hidden className=\"flex flex-col gap-1.5 transition-opacity delay-150 duration-200 starting:opacity-0\">\n            <span className=\"flex h-4 items-center gap-1.5\">\n              <Bone className=\"size-3.5\" />\n              <Bone className=\"h-2.5 w-24\" />\n              {action}\n            </span>\n            <span className=\"flex h-5 items-center\">\n              <Bone className=\"h-3 w-4/5\" />\n            </span>\n            {/* Card titles usually run to two lines; rows usually to one. */}\n            {layout === \"card\" && (\n              <span className=\"-mt-1.5 flex h-5 items-center\">\n                <Bone className=\"h-3 w-1/2\" />\n              </span>\n            )}\n            <span className=\"flex flex-col gap-[7px] py-[4px]\">\n              <Bone className=\"h-2.5 w-full\" />\n              <Bone className=\"h-2.5 w-2/3\" />\n            </span>\n          </div>\n        ) : (\n          <>\n            {siteRow}\n            <p className={cn(\"font-medium tracking-[-0.01em] text-fg text-pretty\", layout === \"card\" ? \"line-clamp-2 text-[14px] leading-5\" : \"line-clamp-2 text-[13.5px] leading-5\")}>\n              {link}\n            </p>\n            {failed ? failure : data?.description && <p className=\"line-clamp-2 text-[12.5px] leading-[18px] text-fg-2 text-pretty\">{data.description}</p>}\n          </>\n        )}\n      </motion.div>\n    </AnimatePresence>\n  );\n\n  // A card with nothing to show up top folds its image slot away (the height glides, see Grow).\n  // An image that was promised but failed keeps the slot, with the site's mark standing in.\n  const showMedia = layout === \"row\" || loading || !!data?.image;\n  const media = !showMedia ? null : loading ? (\n    <div aria-hidden className={cn(\"shrink-0 overflow-hidden bg-frame\", layout === \"card\" ? \"aspect-[1.91/1] border-b border-line\" : \"size-[72px] rounded-lg border border-line\")}>\n      <Bone className=\"size-full rounded-none opacity-60\" />\n    </div>\n  ) : (\n    <Media\n      src={data?.image}\n      alt={data?.imageAlt}\n      favicon={data?.favicon}\n      host={host}\n      className={layout === \"card\" ? \"aspect-[1.91/1] border-b border-line\" : \"size-[72px] shrink-0 rounded-lg border border-line\"}\n    />\n  );\n\n  return layout === \"card\" ? (\n    <Grow>\n      {media}\n      <div className=\"px-3.5 pb-3.5 pt-3\">{text}</div>\n    </Grow>\n  ) : (\n    <div className=\"flex items-start gap-3 p-3\">\n      <Grow className=\"min-w-0 flex-1\">{text}</Grow>\n      {media}\n    </div>\n  );\n}\n\n// Height follows the content on a short in-out curve, so skeleton → content → error\n// never jumps the thread around it. The first measurement lands without animating.\nfunction Grow({ className, children }: { className?: string; children: React.ReactNode }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const reduce = useReducedMotion();\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    let first = true;\n    const ro = new ResizeObserver(([e]) => {\n      const h = e.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight;\n      if (first || reduce) height.set(h);\n      else animate(height, h, { duration: 0.24, ease: ease.inOut });\n      first = false;\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [height, reduce]);\n  return (\n    // clip, not hidden: a hidden box can still be scrolled sideways by focus, which would nudge the text.\n    <motion.div style={{ height }} className={cn(\"overflow-clip\", className)}>\n      <div ref={inner}>{children}</div>\n    </motion.div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The card\n * -----------------------------------------------------------------------------------------------*/\n\nexport type LinkPreviewProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  url: string;\n  /** The preview, when you already have it. */\n  data?: LinkMeta;\n  /** Fetches the preview. Shared and cached per URL across the page. */\n  load?: LinkPreviewLoad;\n  /** \"card\" stacks the image over the text; \"row\" puts a square thumbnail beside it, for chat and lists. */\n  layout?: Layout;\n  openInNewTab?: boolean;\n  /** Shows a remove button. The card collapses its space first, then this is called. */\n  onRemove?: () => void;\n  removeLabel?: string;\n};\n\n/** An unfurled link: image, site, title and description, with its loading, missing and failed states. */\nexport function LinkPreview({\n  url,\n  data,\n  load,\n  layout = \"card\",\n  openInNewTab = true,\n  onRemove,\n  removeLabel = \"Remove preview\",\n  className,\n  ...rest\n}: LinkPreviewProps) {\n  const state = useLinkPreview(url, { data, load });\n  const [leaving, setLeaving] = useState(false);\n  const reduce = useReducedMotion();\n\n  const remove = onRemove && (\n    <button\n      type=\"button\"\n      aria-label={removeLabel}\n      onClick={() => setLeaving(true)}\n      className={cn(\n        \"relative z-10 grid shrink-0 place-items-center rounded-md text-fg-3 outline-none\",\n        layout === \"card\" ? \"size-6 border border-line-2 bg-raised/90 shadow-pop\" : \"-my-1 ml-auto size-6\",\n        \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n        \"transition-[opacity,background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        // Quiet until you're on the card (always there on touch).\n        \"pointer-fine:opacity-0 pointer-fine:group-hover/lp:opacity-100 pointer-fine:focus-visible:opacity-100 pointer-fine:group-has-[a:focus-visible]/lp:opacity-100\",\n      )}\n    >\n      <X size={12} />\n    </button>\n  );\n\n  return (\n    <motion.div\n      animate={leaving ? (reduce ? { opacity: 0 } : { opacity: 0, height: 0, scale: 0.98 }) : undefined}\n      transition={{ duration: 0.22, ease: ease.inOut }}\n      onAnimationComplete={() => leaving && onRemove?.()}\n      className={cn(\"w-full origin-top\", leaving && \"pointer-events-none overflow-hidden\")}\n    >\n      <div\n        data-slot=\"link-preview\"\n        data-layout={layout}\n        data-status={state.status}\n        aria-busy={state.status === \"loading\" || undefined}\n        className={cn(\n          \"group/lp relative w-full overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\",\n          \"transition-[border-color,scale] duration-150 ease-out hover:border-line-2 has-[a:active]:scale-[0.99] has-[a:active]:duration-75\",\n          \"has-[a:focus-visible]:outline-solid has-[a:focus-visible]:outline-1 has-[a:focus-visible]:outline-offset-2 has-[a:focus-visible]:outline-fg-3\",\n          className,\n        )}\n        {...rest}\n      >\n        <Body url={url} state={state} layout={layout} openInNewTab={openInNewTab} action={layout === \"row\" ? remove : undefined} />\n        {layout === \"card\" && remove && <span className=\"absolute right-2 top-2 z-10\">{remove}</span>}\n      </div>\n    </motion.div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Inline link with a hover preview\n * -----------------------------------------------------------------------------------------------*/\n\nexport type LinkPreviewLinkProps = Omit<PreviewCard.Trigger.Props, \"className\" | \"delay\" | \"closeDelay\" | \"href\"> & {\n  href: string;\n  className?: string;\n  data?: LinkMeta;\n  load?: LinkPreviewLoad;\n  /** Milliseconds of hover or focus before the preview opens. The request starts at once. */\n  delay?: number;\n  closeDelay?: number;\n  side?: \"top\" | \"bottom\";\n  openInNewTab?: boolean;\n  /** Where to portal the preview. Defaults to document.body. */\n  container?: PreviewCard.Portal.Props[\"container\"];\n};\n\n/** A link in running text that shows where it goes before you follow it. */\nexport function LinkPreviewLink({\n  href,\n  data,\n  load,\n  delay = 500,\n  closeDelay = 200,\n  side = \"bottom\",\n  openInNewTab = true,\n  container,\n  className,\n  children,\n  onPointerEnter,\n  onFocus,\n  ...rest\n}: LinkPreviewLinkProps) {\n  // Only subscribe to the request once someone shows interest; hovering starts it immediately.\n  const [armed, setArmed] = useState(false);\n  const state = useLinkPreview(href, { data, load, enabled: armed });\n  const arm = () => {\n    setArmed(true);\n    if (load && !data) prefetchLinkPreview(href, load);\n  };\n\n  return (\n    <PreviewCard.Root>\n      <PreviewCard.Trigger\n        href={href}\n        target={openInNewTab ? \"_blank\" : undefined}\n        rel={openInNewTab ? \"noopener noreferrer\" : undefined}\n        delay={delay}\n        closeDelay={closeDelay}\n        onPointerEnter={(e) => {\n          arm();\n          onPointerEnter?.(e);\n        }}\n        onFocus={(e) => {\n          arm();\n          onFocus?.(e);\n        }}\n        className={cn(\n          \"group/link rounded-[3px] font-medium text-fg underline decoration-fg-4 decoration-1 underline-offset-[3px] outline-none\",\n          \"transition-[text-decoration-color] duration-150 ease-out hover:decoration-fg-2 data-popup-open:decoration-fg-2\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n        {openInNewTab && (\n          <ArrowUpRight\n            size={12}\n            className=\"ml-px inline-block align-[-1px] text-fg-4 transition-[translate,color] duration-200 ease-out-expo group-hover/link:-translate-y-px group-hover/link:translate-x-px group-hover/link:text-fg-2\"\n          />\n        )}\n        {openInNewTab && <span className=\"sr-only\"> (opens in a new tab)</span>}\n      </PreviewCard.Trigger>\n      <PreviewCard.Portal container={container}>\n        <PreviewCard.Positioner side={side} align=\"start\" sideOffset={8} collisionPadding={8} className=\"z-(--z-popover)\">\n          <PreviewCard.Popup\n            className={cn(\n              \"group/lp relative w-[300px] max-w-[var(--available-width)] overflow-hidden rounded-xl border border-line-2 bg-raised text-left shadow-pop outline-none\",\n              \"origin-[var(--transform-origin)] transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n              \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n              \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n              \"data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <Body url={href} state={state} layout=\"card\" openInNewTab={openInNewTab} />\n          </PreviewCard.Popup>\n        </PreviewCard.Positioner>\n      </PreviewCard.Portal>\n    </PreviewCard.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/link-preview"
}
