{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hover-card","type":"registry:ui","title":"Hover Card","description":"A preview of what a link or name points to – opens after a short rest of the pointer or on keyboard focus, stays while the pointer moves into it, never on touch, flips to stay on screen","dependencies":[],"files":[{"path":"registry/hover-card/hover-card.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  cloneElement,\n  isValidElement,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type FocusEvent as ReactFocusEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type SyntheticEvent,\n} from \"react\";\n\n/*\n * Hover Card – a single self-contained file.\n * A preview of what a link or a name points to – an agent, a person, a file, a run – that shows when the pointer\n * rests on it and when the keyboard focuses it. It waits a moment before it opens, so passing over a list doesn't\n * flash cards, and stays open while the pointer travels into it (a grace area across the gap), so you can read it,\n * select text or press a link inside. Touch never opens it: a tap follows the link. It lives in the top layer, so no\n * card's overflow cuts it, and flips to stay on screen.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type HoverCardProps = {\n  /** What the card shows – a name, a line, a few facts, a link to the full page */\n  content: ReactNode;\n  /** Exactly one element that takes a ref and focus – usually a link or a button with a name */\n  children: ReactElement;\n  /** Where it opens; it flips to the other side when there is no room */\n  side?: \"bottom\" | \"top\" | \"right\" | \"left\";\n  /** Which edges line up: start (left, or top beside it) · center · end – start and end follow the reading direction */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap to the trigger in px */\n  offset?: number;\n  /** How long the pointer rests (or the keyboard focus stays) before it opens, in ms */\n  openDelay?: number;\n  /** How long it stays after the pointer leaves trigger and card, in ms */\n  closeDelay?: number;\n  /** The card's width – by default it fits its content, up to 320px */\n  width?: number | string;\n  /** Never opens */\n  disabled?: boolean;\n  /** Controls it from outside (with onOpenChange); leave it out and it opens on its own */\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** A class for the card */\n  className?: string;\n};\n\n/** Only one card at a time on the page */\nconst group: { current: { close: () => void } | null } = { current: null };\n\nexport function HoverCard({\n  content,\n  children,\n  side = \"bottom\",\n  align = \"start\",\n  offset = 8,\n  openDelay = 600,\n  closeDelay = 300,\n  width,\n  disabled,\n  open: openProp,\n  onOpenChange,\n  className,\n}: HoverCardProps) {\n  const trigger = useRef<HTMLElement | null>(null);\n  const card = useRef<HTMLDivElement | null>(null);\n  const timer = useRef(0);\n  // On its way into the card: the corridor from where the pointer left the trigger to the card's far edges\n  const corridor = useRef<{ x: number; y: number }[] | null>(null);\n  // After a press or Escape it stays closed until the pointer leaves or the focus goes\n  const blocked = useRef(false);\n  const [shown, setShown] = useState(false);\n  const empty = content == null || typeof content === \"boolean\" || content === \"\";\n  const open = !disabled && !empty && (openProp ?? shown);\n  const isOpen = useRef(open);\n  isOpen.current = open;\n\n  const change = useRef<(v: boolean) => void>(() => {});\n  change.current = (v: boolean) => {\n    window.clearTimeout(timer.current);\n    corridor.current = null;\n    if (openProp === undefined) setShown(v);\n    if (v !== isOpen.current) onOpenChange?.(v);\n  };\n  const me = useRef({ close: () => change.current(false) });\n\n  const show = () => {\n    window.clearTimeout(timer.current);\n    if (disabled || empty || blocked.current || isOpen.current) return;\n    timer.current = window.setTimeout(() => change.current(true), Math.max(0, openDelay));\n  };\n  const hide = () => {\n    window.clearTimeout(timer.current);\n    if (!isOpen.current) return;\n    timer.current = window.setTimeout(() => change.current(false), Math.max(0, closeDelay));\n  };\n  const stay = () => {\n    window.clearTimeout(timer.current);\n    corridor.current = null;\n  };\n\n  // One at a time: opening this one closes the other\n  useEffect(() => {\n    if (!open) return;\n    const self = me.current;\n    if (group.current && group.current !== self) group.current.close();\n    group.current = self;\n    return () => {\n      if (group.current === self) group.current = null;\n    };\n  }, [open]);\n\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // Into the top layer next to the trigger before paint; it follows on scroll and resize\n  useLayoutEffect(() => {\n    const el = card.current;\n    if (!open || !el) return;\n    showLayer(el);\n    const update = () => trigger.current && place(el, trigger.current, side, align, offset);\n    update();\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(update);\n    ro?.observe(el);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      ro?.disconnect();\n      hideLayer(el);\n    };\n  }, [open, side, align, offset]);\n\n  useEffect(() => {\n    if (!open) return;\n    // Escape closes it first – caught early, so a dialog around it stays open\n    const key = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || e.defaultPrevented) return;\n      e.preventDefault();\n      e.stopPropagation();\n      blocked.current = true;\n      const back = card.current?.contains(document.activeElement);\n      change.current(false);\n      if (back) trigger.current?.focus({ preventScroll: true });\n    };\n    // The grace area: inside the corridor it stays; out of it (and off trigger and card) the close delay starts\n    const move = (e: PointerEvent) => {\n      const c = corridor.current;\n      if (!c || e.pointerType === \"touch\") return;\n      if (!inHull(c, e.clientX, e.clientY)) {\n        corridor.current = null;\n        hide();\n      }\n    };\n    // A tap or press anywhere else closes it at once (on touch, nothing else would)\n    const down = (e: PointerEvent) => {\n      const t = e.target as Node;\n      if (!card.current?.contains(t) && !trigger.current?.contains(t)) change.current(false);\n    };\n    window.addEventListener(\"keydown\", key, true);\n    document.addEventListener(\"pointermove\", move);\n    document.addEventListener(\"pointerdown\", down, true);\n    return () => {\n      window.removeEventListener(\"keydown\", key, true);\n      document.removeEventListener(\"pointermove\", move);\n      document.removeEventListener(\"pointerdown\", down, true);\n    };\n  }, [open]);\n\n  if (!isValidElement(children)) return <>{children}</>;\n  const own = children.props as Record<string, unknown> & { ref?: Ref<HTMLElement> };\n  const call = (name: string, e: SyntheticEvent) => (own[name] as ((e: SyntheticEvent) => void) | undefined)?.(e);\n  // The child's handler runs first; when it calls preventDefault, the card leaves the event alone\n  const wrap =\n    <E extends SyntheticEvent>(name: string, mine: (e: E) => void) =>\n    (e: E) => {\n      call(name, e);\n      if (!e.defaultPrevented) mine(e);\n    };\n\n  /** Leaving trigger or card: toward the card the corridor keeps it open, anywhere else the close delay starts */\n  const leave = (e: ReactPointerEvent, fromTrigger: boolean) => {\n    blocked.current = false;\n    if (e.pointerType === \"touch\") return;\n    const r = card.current?.getBoundingClientRect();\n    const to = e.relatedTarget as Node | null;\n    if (to instanceof Node && (card.current?.contains(to) || trigger.current?.contains(to))) return stay();\n    if (fromTrigger && isOpen.current && r) {\n      corridor.current = hull([{ x: e.clientX, y: e.clientY }, { x: r.left, y: r.top }, { x: r.right, y: r.top }, { x: r.right, y: r.bottom }, { x: r.left, y: r.bottom }]);\n      if (inHull(corridor.current, e.clientX, e.clientY)) return window.clearTimeout(timer.current);\n    }\n    corridor.current = null;\n    if (isOpen.current) hide();\n    else window.clearTimeout(timer.current);\n  };\n\n  const trig = cloneElement(children as ReactElement<Record<string, unknown>>, {\n    ref: (el: HTMLElement | null) => {\n      trigger.current = el;\n      const r = own.ref;\n      if (typeof r === \"function\") r(el);\n      else if (r) (r as { current: HTMLElement | null }).current = el;\n    },\n    // Touch has no hover: a tap follows the link and never opens it\n    onPointerEnter: wrap(\"onPointerEnter\", (e: ReactPointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      if (isOpen.current) stay();\n      else show();\n    }),\n    onPointerLeave: wrap(\"onPointerLeave\", (e: ReactPointerEvent) => leave(e, true)),\n    // A press means the link is followed or the button acts – the card gets out of the way\n    onPointerDown: wrap(\"onPointerDown\", () => {\n      blocked.current = true;\n      change.current(false);\n    }),\n    // Keyboard focus opens it after the same wait; a click's focus does not\n    onFocus: wrap(\"onFocus\", (e: ReactFocusEvent) => {\n      let visible = true;\n      try {\n        visible = (e.currentTarget as HTMLElement).matches(\":focus-visible\");\n      } catch {}\n      if (visible) show();\n    }),\n    onBlur: wrap(\"onBlur\", (e: ReactFocusEvent) => {\n      blocked.current = false;\n      // Tab goes on into the card (it follows the trigger) – it stays open for its links\n      if (e.relatedTarget instanceof Node && card.current?.contains(e.relatedTarget)) return;\n      if (isOpen.current) change.current(false);\n      else window.clearTimeout(timer.current);\n    }),\n  });\n\n  return (\n    <>\n      {/* React 19 hoists this into <head> once, no matter how many cards render */}\n      <style href=\"ui-hover-card\" precedence=\"default\">\n        {css}\n      </style>\n      {trig}\n      {open && (\n        <div\n          ref={card}\n          className={[\"ui-hvc\", className].filter(Boolean).join(\" \")}\n          popover=\"manual\"\n          data-side={side}\n          data-align={align}\n          style={width != null ? ({ width, maxWidth: \"calc(100vw - 16px)\" } as CSSProperties) : undefined}\n          onPointerEnter={(e) => e.pointerType !== \"touch\" && stay()}\n          onPointerLeave={(e) => leave(e, false)}\n          onBlur={(e) => {\n            const to = e.relatedTarget;\n            if (to instanceof Node && (card.current?.contains(to) || trigger.current?.contains(to))) return;\n            change.current(false);\n          }}\n        >\n          {content}\n        </div>\n      )}\n    </>\n  );\n}\n\n/* ---------- Helpers ---------- */\n\ntype Point = { x: number; y: number };\n\n/** The convex hull of a few points (monotone chain) – the corridor from the exit point to the card */\nfunction hull(points: Point[]) {\n  const p = [...points].sort((a, b) => a.x - b.x || a.y - b.y);\n  const cross = (o: Point, a: Point, b: Point) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);\n  const lower: Point[] = [];\n  for (const q of p) {\n    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], q) <= 0) lower.pop();\n    lower.push(q);\n  }\n  const upper: Point[] = [];\n  for (const q of [...p].reverse()) {\n    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], q) <= 0) upper.pop();\n    upper.push(q);\n  }\n  return lower.slice(0, -1).concat(upper.slice(0, -1));\n}\n\n/** Inside the convex polygon, with a few px to spare for a shaky hand */\nfunction inHull(poly: Point[], x: number, y: number) {\n  if (poly.length < 3) return false;\n  for (let i = 0; i < poly.length; i++) {\n    const a = poly[i];\n    const b = poly[(i + 1) % poly.length];\n    const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;\n    // Counter-clockwise hull: a point to the right of an edge by more than the slack is outside\n    if (((b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x)) / len < -4) return false;\n  }\n  return true;\n}\n\n/** The top layer, so no overflow cuts it and no z-index fights it */\nfunction showLayer(el: HTMLElement) {\n  if (\"showPopover\" in el && !el.matches(\":popover-open\")) el.showPopover();\n}\nfunction hideLayer(el: HTMLElement) {\n  if (\"hidePopover\" in el && el.isConnected && el.matches(\":popover-open\")) el.hidePopover();\n}\n\n/**\n * Puts the fixed card next to the trigger: on the preferred side, or the other one when that has more room (beside it\n * with no room either way: under or above it); the edge it lines up with switches when it would stick out, and it is\n * pushed inside the window as a last resort. Too tall for the room it gets: it scrolls.\n */\nfunction place(el: HTMLElement, trigger: HTMLElement, side: \"top\" | \"bottom\" | \"left\" | \"right\", align: \"start\" | \"center\" | \"end\", gap: number) {\n  const pad = 8;\n  const r = trigger.getBoundingClientRect();\n  const vw = document.documentElement.clientWidth || window.innerWidth;\n  const vh = window.innerHeight;\n  el.style.maxHeight = \"\";\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  const room = { bottom: vh - r.bottom - gap - pad, top: r.top - gap - pad, right: vw - r.right - gap - pad, left: r.left - gap - pad };\n  const flip = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" } as const;\n  let s: keyof typeof room = side;\n  if (room[s] < (s === \"top\" || s === \"bottom\" ? h : w) && room[flip[s]] > room[s]) s = flip[s];\n  if ((s === \"left\" || s === \"right\") && room[s] < w) s = room.bottom >= room.top ? \"bottom\" : \"top\";\n  // start and end follow the reading direction above and below\n  const rtl = getComputedStyle(trigger).direction === \"rtl\";\n  let a = (s === \"top\" || s === \"bottom\") && rtl && align !== \"center\" ? (align === \"start\" ? \"end\" : \"start\") : align;\n  let top: number;\n  let left: number;\n  if (s === \"top\" || s === \"bottom\") {\n    const fit = Math.min(h, Math.max(120, room[s]));\n    top = s === \"bottom\" ? r.bottom + gap : r.top - gap - fit;\n    left = a === \"start\" ? r.left : a === \"end\" ? r.right - w : r.left + r.width / 2 - w / 2;\n    if (a === \"start\" && left + w > vw - pad && r.right - w >= pad) (a = \"end\"), (left = r.right - w);\n    else if (a === \"end\" && left < pad && r.left + w <= vw - pad) (a = \"start\"), (left = r.left);\n    left = Math.min(Math.max(pad, left), vw - pad - w);\n    if (h > fit) el.style.maxHeight = `${fit}px`;\n  } else {\n    left = s === \"right\" ? r.right + gap : r.left - gap - w;\n    top = a === \"start\" ? r.top : a === \"end\" ? r.bottom - h : r.top + r.height / 2 - h / 2;\n    top = Math.min(Math.max(pad, top), Math.max(pad, vh - pad - h));\n    if (h > vh - 2 * pad) el.style.maxHeight = `${vh - 2 * pad}px`;\n  }\n  el.style.top = `${Math.round(top)}px`;\n  el.style.left = `${Math.round(left)}px`;\n  el.dataset.side = s;\n  el.dataset.align = a;\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-hvc (see Theming in the docs) */\n.ui-hvc {\n  --ui-hvc-surface: var(--aui-overlay, var(--aui-surface, #ffffff));\n  --ui-hvc-ink: var(--aui-ink, #171717);\n  --ui-hvc-shadow: var(--aui-shadow-popover, 0 0 0 1px rgba(0, 0, 0, 0.06), 0 18px 40px -12px rgba(0, 0, 0, 0.18), 0 4px 12px -4px rgba(0, 0, 0, 0.06));\n  --ui-hvc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-hvc-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n\n  /* A white card in the top layer: a 1px edge and a deeper drop. It fits its content up to 320px; width lifts the cap */\n  position: fixed;\n  inset: auto;\n  z-index: 1000;\n  display: block;\n  box-sizing: border-box;\n  width: max-content;\n  max-width: min(320px, calc(100vw - 16px));\n  max-height: calc(100vh - 16px);\n  margin: 0;\n  padding: 16px;\n  border: 0;\n  border-radius: calc(var(--aui-radius-lg, 10px) + 6px);\n  background: var(--ui-hvc-surface);\n  box-shadow: var(--ui-hvc-shadow);\n  color: var(--ui-hvc-ink);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13.5px;\n  line-height: 20px;\n  font-weight: 400;\n  text-align: start;\n  overflow: auto;\n  overflow-wrap: anywhere;\n  overscroll-behavior: contain;\n  -webkit-font-smoothing: antialiased;\n  transform-origin: top left;\n  animation: ui-hvc-in 200ms var(--ui-hvc-ease) both;\n}\n.ui-hvc::backdrop { display: none; }\n.ui-hvc *, .ui-hvc *::before, .ui-hvc *::after { box-sizing: border-box; }\n/* Links and buttons you put inside get the library's ring */\n.ui-hvc :is(a, button, [tabindex]):focus-visible { outline: 2px solid var(--ui-hvc-focus); outline-offset: 2px; }\n\n/* It grows out of the side and edge it is attached to */\n.ui-hvc[data-align=\"end\"] { transform-origin: top right; }\n.ui-hvc[data-align=\"center\"] { transform-origin: top center; }\n.ui-hvc[data-side=\"top\"] { transform-origin: bottom left; animation-name: ui-hvc-in-up; }\n.ui-hvc[data-side=\"top\"][data-align=\"end\"] { transform-origin: bottom right; }\n.ui-hvc[data-side=\"top\"][data-align=\"center\"] { transform-origin: bottom center; }\n.ui-hvc[data-side=\"right\"] { transform-origin: top left; animation-name: ui-hvc-in-right; }\n.ui-hvc[data-side=\"right\"][data-align=\"end\"] { transform-origin: bottom left; }\n.ui-hvc[data-side=\"right\"][data-align=\"center\"] { transform-origin: center left; }\n.ui-hvc[data-side=\"left\"] { transform-origin: top right; animation-name: ui-hvc-in-left; }\n.ui-hvc[data-side=\"left\"][data-align=\"end\"] { transform-origin: bottom right; }\n.ui-hvc[data-side=\"left\"][data-align=\"center\"] { transform-origin: center right; }\n\n@keyframes ui-hvc-in { from { opacity: 0; transform: scale(0.97) translateY(-4px); } }\n@keyframes ui-hvc-in-up { from { opacity: 0; transform: scale(0.97) translateY(4px); } }\n@keyframes ui-hvc-in-right { from { opacity: 0; transform: scale(0.97) translateX(-4px); } }\n@keyframes ui-hvc-in-left { from { opacity: 0; transform: scale(0.97) translateX(4px); } }\n\n/* Touch screens with a mouse or pen (a tablet): 15px text, so it reads like the rest of the UI there */\n@media (pointer: coarse) {\n  .ui-hvc { padding: 16px; font-size: 15px; line-height: 22px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-hvc { animation: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-hvc {\n  --ui-hvc-surface: var(--aui-overlay, var(--aui-surface, #19191b));\n  --ui-hvc-ink: var(--aui-ink, #ededed);\n  --ui-hvc-shadow: var(--aui-shadow-popover, 0 0 0 1px rgba(255, 255, 255, 0.06), 0 18px 40px -12px rgba(0, 0, 0, 0.6));\n  --ui-hvc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  color-scheme: dark;\n}\n`;\n"}]}