{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "link-preview",
  "type": "registry:ui",
  "title": "Link Preview",
  "description": "An inline link that springs out a preview card on hover or keyboard focus, with collision-aware placement.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/link-preview.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useMotionValue, useSpring } from \"motion/react\";\n\nexport interface LinkPreviewData {\n  title: string;\n  description?: string;\n  /** Image URL or data URI shown at the top of the card. */\n  image?: string;\n  /** Alt text for the image; leave empty when it is decorative. */\n  imageAlt?: string;\n  /** Small mono line, e.g. the domain or \"Article · 6 min\". Defaults to the link's hostname. */\n  meta?: string;\n}\n\nexport interface LinkPreviewProps extends Omit<React.ComponentPropsWithoutRef<\"a\">, \"children\"> {\n  href: string;\n  /** Link text. */\n  children: React.ReactNode;\n  /** Card content — provided by you; nothing is fetched. */\n  preview: LinkPreviewData;\n  /** Preferred side. Flips automatically when there is not enough room. */\n  side?: \"top\" | \"bottom\";\n  /** Hover / focus delay before the card opens, in ms. */\n  openDelay?: number;\n  /** Delay before closing after the pointer leaves, so it can travel into the card, in ms. */\n  closeDelay?: number;\n  /** Let the card drift slightly with the pointer along the link. */\n  followCursor?: boolean;\n  /** Card width in px (capped to the viewport). */\n  width?: number;\n  /** Underline and focus colour. */\n  accent?: string;\n  /** Label of the \"open\" link shown in the card after a first tap on touch screens. */\n  openLabel?: string;\n}\n\nconst GAP = 10; // px between link and card\nconst EDGE = 8; // px kept from the viewport edge\nconst FOLLOW = 0.14; // fraction of the pointer offset the card follows\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** Hostname of absolute URLs (\"motion.dev\"); relative hrefs are shown as written. */\nfunction hostOf(href: string) {\n  if (!/^[a-z][a-z\\d+.-]*:\\/\\//i.test(href)) return href;\n  try {\n    return new URL(href).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return href;\n  }\n}\n\n/**\n * An inline link that shows a floating preview card on hover (after a delay) or keyboard focus.\n * The card lives in the top layer (Popover API), flips above / below to stay in view, springs out\n * of the link and stays open while the pointer moves into it. On touch, the first tap previews and\n * the second tap follows the link.\n */\nexport function LinkPreview({\n  href,\n  children,\n  preview,\n  side = \"top\",\n  openDelay = 350,\n  closeDelay = 180,\n  followCursor = true,\n  width = 300,\n  accent = \"#FF7448\",\n  openLabel = \"Open link\",\n  className = \"\",\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  onPointerMove,\n  onPointerDown,\n  onFocus,\n  onBlur,\n  onClick,\n  ...props\n}: LinkPreviewProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const descId = `${uid}-desc`;\n  const [open, setOpen] = React.useState(false);\n  /** Opened by a tap: the card becomes interactive and offers an explicit \"open\" link. */\n  const [touchMode, setTouchMode] = React.useState(false);\n\n  const anchorRef = React.useRef<HTMLAnchorElement>(null);\n  const cardRef = React.useRef<HTMLSpanElement | null>(null);\n  const openTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const closeTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const pointerType = React.useRef(\"mouse\");\n  const pointerY = React.useRef<number | null>(null);\n  const geometry = React.useRef({ cx: 0, min: 0, max: 0 });\n\n  const followRaw = useMotionValue(0);\n  const followX = useSpring(followRaw, { stiffness: 260, damping: 26 });\n\n  const host = hostOf(href);\n\n  const clearTimers = () => {\n    clearTimeout(openTimer.current);\n    clearTimeout(closeTimer.current);\n  };\n  const scheduleOpen = (delay: number) => {\n    clearTimeout(closeTimer.current);\n    if (open) return;\n    clearTimeout(openTimer.current);\n    openTimer.current = setTimeout(() => setOpen(true), Math.max(0, delay));\n  };\n  const hide = () => {\n    clearTimers();\n    setOpen(false);\n    setTouchMode(false);\n  };\n  const scheduleClose = (delay: number) => {\n    clearTimeout(openTimer.current);\n    clearTimeout(closeTimer.current);\n    closeTimer.current = setTimeout(hide, Math.max(0, delay));\n  };\n\n  /** Places the card above or below the link, flipping and clamping to stay inside the viewport. */\n  const position = React.useCallback(() => {\n    const card = cardRef.current;\n    const anchor = anchorRef.current;\n    if (!card || !anchor) return;\n    // Wrapped links have several line boxes: use the one under the pointer, else the first.\n    const rects = Array.from(anchor.getClientRects());\n    const y = pointerY.current;\n    const rect =\n      (y !== null ? rects.find((r) => y >= r.top - 2 && y <= r.bottom + 2) : undefined) ?? rects[0] ?? anchor.getBoundingClientRect();\n    const w = card.offsetWidth;\n    const h = card.offsetHeight;\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const roomAbove = rect.top - GAP - EDGE;\n    const roomBelow = vh - rect.bottom - GAP - EDGE;\n    const above = side === \"top\" ? roomAbove >= h || roomAbove >= roomBelow : !(roomBelow >= h || roomBelow >= roomAbove);\n    const cx = rect.left + rect.width / 2;\n    const left = Math.min(Math.max(EDGE, cx - w / 2), Math.max(EDGE, vw - w - EDGE));\n    const top = above ? rect.top - GAP - h : rect.bottom + GAP;\n    card.style.left = `${left}px`;\n    card.style.top = `${top}px`;\n    card.style.transformOrigin = `${Math.min(w, Math.max(0, cx - left))}px ${above ? \"100%\" : \"0%\"}`;\n    card.dataset.side = above ? \"top\" : \"bottom\";\n    geometry.current = { cx, min: EDGE - left, max: vw - EDGE - w - left };\n  }, [side]);\n\n  /** Mounts the card into the top layer and positions it before the first paint of the animation. */\n  const mountCard = React.useCallback(\n    (node: HTMLSpanElement | null) => {\n      cardRef.current = node;\n      if (!node) return;\n      if (typeof node.showPopover === \"function\" && !node.matches(\":popover-open\")) {\n        try {\n          node.showPopover();\n        } catch {\n          // Already shown or detached: the fixed-position fallback still works.\n        }\n      }\n      position();\n    },\n    [position],\n  );\n\n  // While open: keep the card attached on scroll / resize, close on Escape and outside taps.\n  React.useEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const reposition = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(position);\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        clearTimeout(openTimer.current);\n        clearTimeout(closeTimer.current);\n        setOpen(false);\n        setTouchMode(false);\n      }\n    };\n    const onPointerDownOutside = (event: PointerEvent) => {\n      const target = event.target as Node;\n      if (anchorRef.current?.contains(target) || cardRef.current?.contains(target)) return;\n      setOpen(false);\n      setTouchMode(false);\n    };\n    window.addEventListener(\"scroll\", reposition, { passive: true, capture: true });\n    window.addEventListener(\"resize\", reposition);\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onPointerDownOutside);\n    return () => {\n      cancelAnimationFrame(frame);\n      window.removeEventListener(\"scroll\", reposition, { capture: true });\n      window.removeEventListener(\"resize\", reposition);\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onPointerDownOutside);\n    };\n  }, [open, position]);\n\n  React.useEffect(() => clearTimers, []);\n\n  const isMouse = (event: React.PointerEvent) => event.pointerType === \"mouse\";\n\n  return (\n    <span className=\"relative inline\" style={{ [\"--lp-accent\" as string]: accent }}>\n      <a\n        ref={anchorRef}\n        href={href}\n        aria-describedby={descId}\n        className={`rounded-[3px] font-medium text-inherit underline decoration-[color-mix(in_oklab,var(--lp-accent)_70%,transparent)] decoration-1 underline-offset-[3px] transition-[text-decoration-color,text-decoration-thickness] duration-150 hover:decoration-[var(--lp-accent)] hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--lp-accent)] ${open ? \"decoration-[var(--lp-accent)] decoration-2\" : \"\"} ${className}`}\n        style={style}\n        onPointerDown={(event) => {\n          onPointerDown?.(event);\n          pointerType.current = event.pointerType;\n        }}\n        onPointerEnter={(event) => {\n          onPointerEnter?.(event);\n          if (!isMouse(event)) return;\n          pointerType.current = \"mouse\";\n          pointerY.current = event.clientY;\n          scheduleOpen(openDelay);\n        }}\n        onPointerMove={(event) => {\n          onPointerMove?.(event);\n          if (!isMouse(event)) return;\n          pointerY.current = event.clientY;\n          if (!followCursor || reduceMotion || !open) return;\n          const { cx, min, max } = geometry.current;\n          followRaw.set(Math.min(max, Math.max(min, (event.clientX - cx) * FOLLOW)));\n        }}\n        onPointerLeave={(event) => {\n          onPointerLeave?.(event);\n          if (!isMouse(event)) return;\n          scheduleClose(closeDelay);\n        }}\n        onFocus={(event) => {\n          onFocus?.(event);\n          if (event.currentTarget.matches(\":focus-visible\")) {\n            pointerY.current = null;\n            scheduleOpen(openDelay);\n          }\n        }}\n        onBlur={(event) => {\n          onBlur?.(event);\n          if (cardRef.current?.contains(event.relatedTarget as Node | null)) return;\n          scheduleClose(touchMode ? closeDelay : 0);\n        }}\n        onClick={(event) => {\n          // Touch: the first tap previews, the second follows the link.\n          if (pointerType.current !== \"mouse\" && event.detail > 0 && !(open && touchMode)) {\n            event.preventDefault();\n            clearTimers();\n            pointerY.current = null;\n            setTouchMode(true);\n            setOpen(true);\n          }\n          onClick?.(event);\n        }}\n        {...props}\n      >\n        {children}\n      </a>\n      {/* Accessible description; hidden elements still contribute to aria-describedby. */}\n      <span id={descId} hidden>\n        {[preview.title, preview.description].filter(Boolean).join(\". \")}\n      </span>\n\n      <AnimatePresence\n        onExitComplete={() => {\n          followRaw.jump(0);\n          followX.jump(0);\n        }}\n      >\n        {open && (\n          <motion.span\n            key=\"card\"\n            ref={mountCard}\n            popover=\"manual\"\n            aria-hidden={touchMode ? undefined : true}\n            onPointerEnter={(event) => {\n              if (isMouse(event)) clearTimeout(closeTimer.current);\n            }}\n            onPointerLeave={(event) => {\n              if (isMouse(event)) scheduleClose(closeDelay);\n            }}\n            onBlur={(event) => {\n              const next = event.relatedTarget as Node | null;\n              if (!event.currentTarget.contains(next) && next !== anchorRef.current) scheduleClose(closeDelay);\n            }}\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.86 }}\n            animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94, transition: { duration: 0.12 } }}\n            transition={reduceMotion ? { duration: 0.01 } : { type: \"spring\", stiffness: 420, damping: 30, mass: 0.8 }}\n            className=\"fixed inset-auto z-50 m-0 block overflow-visible border-0 bg-transparent p-0 text-left text-zinc-900 dark:text-zinc-100\"\n            style={{ x: followX, width: `min(${width}px, calc(100vw - ${EDGE * 2}px))` }}\n          >\n            <span className=\"block overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_24px_48px_-24px_rgb(0_0_0/0.35),0_2px_6px_-2px_rgb(0_0_0/0.08)] dark:border-white/10 dark:bg-[#1a1d21] dark:shadow-[0_24px_48px_-20px_rgb(0_0_0/0.7)]\">\n              {preview.image ? (\n                <span className=\"block aspect-[16/9] w-full overflow-hidden border-b border-zinc-900/10 bg-zinc-100 dark:border-white/10 dark:bg-zinc-800\">\n                  {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n                  <img src={preview.image} alt={preview.imageAlt ?? \"\"} draggable={false} className=\"block size-full object-cover\" />\n                </span>\n              ) : null}\n              <span className=\"block p-3.5\">\n                <span className=\"block truncate font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n                  {preview.meta ?? host}\n                </span>\n                <span className=\"mt-1 block text-[15px] font-medium leading-snug tracking-tight\">{preview.title}</span>\n                {preview.description ? (\n                  <span className=\"mt-1 line-clamp-3 block text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-400\">{preview.description}</span>\n                ) : null}\n                {touchMode ? (\n                  <a\n                    href={href}\n                    target={props.target}\n                    rel={props.rel}\n                    className=\"mt-3 inline-flex h-8 items-center gap-1.5 rounded-full bg-[var(--lp-accent)] px-3.5 text-[13px] font-medium text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--lp-accent)]\"\n                  >\n                    {openLabel}\n                    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3\">\n                      <path d=\"M5 11l6-6M6 5h5v5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                    </svg>\n                  </a>\n                ) : (\n                  <span className=\"mt-3 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n                    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3\">\n                      <path d=\"M5 11l6-6M6 5h5v5\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                    </svg>\n                    {host}\n                  </span>\n                )}\n              </span>\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/link-preview"
}
