{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hover-card","type":"registry:component","title":"Hover card","description":"A preview of what a link points to: it rises off the link after a rest, stays open while you move onto it, and closes after a short grace.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/hover-card.tsx","type":"registry:component","content":"// Hairline UI · hover-card · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { enterStyle, themeVars, useFloating, usePresence, type Align, type Side } from \"./floating\";\nimport { useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * HOVER CARD: a preview of what a link points to\n *\n *   rest     after resting on the link (600ms) the card rises off\n *            it out of a blur, from the point nearest to it\n *   travel   moving from the link onto the card keeps it open, so\n *            its buttons can be used; leaving both closes it after\n *            a short grace (300ms), so a diagonal path doesn't\n *   keys     focusing the link from the keyboard opens it at once;\n *            Escape closes it\n *\n * Touch has no hover: a tap follows the link, as people expect.\n * The card adds to the link, so nothing in it should be the only\n * way to reach something.\n * ───────────────────────────────────────────────────────── */\n\nexport interface HoverCardProps {\n  /** The link or name it previews. */\n  children: React.ReactElement<{ \"aria-describedby\"?: string }>;\n  content: React.ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Rest before it opens (ms). */\n  openDelay?: number;\n  /** Grace before it closes once the pointer has left both (ms). */\n  closeDelay?: number;\n  width?: number;\n  className?: string;\n}\n\nexport function HoverCard({ children, content, side = \"bottom\", align = \"start\", openDelay = 600, closeDelay = 300, width = 300, className = \"\" }: HoverCardProps) {\n  const reduced = useInteractionReducedMotion();\n  const anchor = React.useRef<HTMLSpanElement>(null);\n  const surface = React.useRef<HTMLDivElement>(null);\n  const [open, setOpen] = React.useState(false);\n  const timer = React.useRef(0);\n  const id = React.useId();\n  const { mounted, shown } = usePresence(open, 160);\n  const place = useFloating({ open: mounted, anchor, floating: surface, side, align, offset: 8 });\n  const [vars, setVars] = React.useState<React.CSSProperties>({});\n\n  const later = (next: boolean, ms: number) => {\n    window.clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setOpen(next), ms);\n  };\n  const enter = (e: React.PointerEvent) => e.pointerType === \"mouse\" && later(true, open ? 0 : openDelay);\n  const leave = (e: React.PointerEvent) => e.pointerType === \"mouse\" && later(false, closeDelay);\n\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open]);\n  React.useLayoutEffect(() => {\n    if (mounted) setVars(themeVars(anchor.current));\n  }, [mounted]);\n\n  return (\n    <>\n      <span\n        ref={anchor}\n        className=\"inline\"\n        onPointerEnter={enter}\n        onPointerLeave={leave}\n        onFocus={(e) => {\n          if ((e.target as HTMLElement).matches(\":focus-visible\")) {\n            window.clearTimeout(timer.current);\n            setOpen(true);\n          }\n        }}\n        onBlur={(e) => {\n          if (!surface.current?.contains(e.relatedTarget as Node)) later(false, 0);\n        }}\n      >\n        {React.cloneElement(children, { \"aria-describedby\": open ? id : children.props[\"aria-describedby\"] })}\n      </span>\n      {mounted &&\n        createPortal(\n          <div\n            ref={surface}\n            id={id}\n            role=\"tooltip\"\n            onPointerEnter={enter}\n            onPointerLeave={leave}\n            className={`fixed left-0 top-0 z-[55] max-w-[calc(100vw_-_16px)] rounded-[16px] bg-popover p-4 text-[13.5px] text-popover-foreground shadow-[0_0_0_1px_var(--border)] ${className}`}\n            style={{ ...vars, width, left: place.x, top: place.y, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }}\n          >\n            {content}\n          </div>,\n          document.body,\n        )}\n    </>\n  );\n}\n\nexport default HoverCard;\n"},{"path":"registry/floating.ts","type":"registry:component","content":"// Hairline UI · hover-card · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FLOATING: what every popover, menu and tooltip shares\n *\n *   place     beside an anchor on a side, lined up start, centre\n *             or end; flips to the other side when it doesn't fit\n *             and shifts to stay `padding` inside the viewport;\n *             follows scroll and resize\n *   presence  mounts on open, shows a frame later (so the entry\n *             transitions run), hides, then unmounts after `exit`\n *   dismiss   Escape and a press outside both close it\n *   theme     the portal carries the anchor's theme tokens, so a\n *             light specimen on a dark page stays light\n * ───────────────────────────────────────────────────────── */\n\nexport type Side = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type Align = \"start\" | \"center\" | \"end\";\n\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\", \"--card\"];\n\n/** The anchor's theme tokens as inline styles for something portalled out of its tree. */\nexport function themeVars(anchor: Element | null): React.CSSProperties {\n  if (!anchor) return {};\n  const style = getComputedStyle(anchor);\n  const vars: Record<string, string> = {};\n  for (const t of TOKENS) {\n    const v = style.getPropertyValue(t);\n    if (v) vars[t] = v;\n  }\n  return vars as React.CSSProperties;\n}\n\nconst OPPOSITE: Record<Side, Side> = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\n\nexport interface Placement {\n  x: number;\n  y: number;\n  side: Side;\n  /** Where the surface grows from, for transform-origin. */\n  origin: string;\n  ready: boolean;\n}\n\n/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */\nexport function useFloating({\n  open,\n  anchor,\n  point,\n  floating,\n  side = \"bottom\",\n  align = \"center\",\n  offset = 8,\n  padding = 8,\n}: {\n  open: boolean;\n  anchor?: React.RefObject<Element | null>;\n  point?: { x: number; y: number } | null;\n  floating: React.RefObject<HTMLElement | null>;\n  side?: Side;\n  align?: Align;\n  offset?: number;\n  padding?: number;\n}): Placement {\n  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: \"center\", ready: false });\n\n  React.useLayoutEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const measure = () => {\n      const el = floating.current;\n      if (!el) return;\n      const a = point\n        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }\n        : anchor?.current?.getBoundingClientRect();\n      if (!a) return;\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };\n      const need = side === \"top\" || side === \"bottom\" ? h + offset : w + offset;\n      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;\n      let x: number;\n      let y: number;\n      if (s === \"top\" || s === \"bottom\") {\n        y = s === \"bottom\" ? a.bottom + offset : a.top - offset - h;\n        x = align === \"start\" ? a.left : align === \"end\" ? a.right - w : a.left + a.width / 2 - w / 2;\n        if (point) x = a.left;\n      } else {\n        x = s === \"right\" ? a.right + offset : a.left - offset - w;\n        y = align === \"start\" ? a.top : align === \"end\" ? a.bottom - h : a.top + a.height / 2 - h / 2;\n      }\n      x = Math.max(padding, Math.min(vw - padding - w, x));\n      y = Math.max(padding, Math.min(vh - padding - h, y));\n      // Grow from the point on the anchor's edge nearest the surface.\n      const ox = s === \"left\" ? \"100%\" : s === \"right\" ? \"0%\" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;\n      const oy = s === \"top\" ? \"100%\" : s === \"bottom\" ? \"0%\" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;\n      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };\n      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    };\n    measure();\n    const ro = new ResizeObserver(schedule);\n    if (floating.current) ro.observe(floating.current);\n    window.addEventListener(\"scroll\", schedule, { capture: true, passive: true });\n    window.addEventListener(\"resize\", schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      window.removeEventListener(\"scroll\", schedule, { capture: true });\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [open, anchor, point, floating, side, align, offset, padding]);\n\n  return open ? place : { ...place, ready: false };\n}\n\n/** Mount → show next frame → hide → unmount after `exit` ms. */\nexport function usePresence(open: boolean, exit = 160) {\n  const [mounted, setMounted] = React.useState(open);\n  const [shown, setShown] = React.useState(false);\n  if (open && !mounted) setMounted(true);\n  React.useEffect(() => {\n    if (open) {\n      let inner = 0;\n      const outer = requestAnimationFrame(() => {\n        inner = requestAnimationFrame(() => setShown(true));\n      });\n      return () => {\n        cancelAnimationFrame(outer);\n        cancelAnimationFrame(inner);\n      };\n    }\n    const timer = window.setTimeout(() => {\n      setMounted(false);\n      setShown(false);\n    }, exit);\n    return () => window.clearTimeout(timer);\n  }, [open, exit]);\n  return { mounted: mounted || open, shown: open && shown };\n}\n\n/** Escape and presses outside `refs` call onDismiss while `open`. */\nexport function useDismiss(open: boolean, onDismiss: (reason: \"escape\" | \"outside\") => void, refs: React.RefObject<Element | null>[]) {\n  const latest = React.useRef({ onDismiss, refs });\n  React.useEffect(() => {\n    latest.current = { onDismiss, refs };\n  });\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      e.stopPropagation();\n      latest.current.onDismiss(\"escape\");\n    };\n    const onDown = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (latest.current.refs.some((r) => r.current?.contains(target))) return;\n      latest.current.onDismiss(\"outside\");\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown, true);\n    };\n  }, [open]);\n}\n\n/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */\nexport function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = \"cubic-bezier(0.16,1,0.3,1)\"): React.CSSProperties {\n  const d = 6;\n  const shift = side === \"top\" ? `0 ${d}px` : side === \"bottom\" ? `0 -${d}px` : side === \"left\" ? `${d}px 0` : `-${d}px 0`;\n  return {\n    opacity: shown ? 1 : 0,\n    translate: shown || reduced ? \"0 0\" : shift,\n    scale: shown || reduced ? \"1\" : \"0.96\",\n    filter: shown || reduced ? \"blur(0px)\" : \"blur(4px)\",\n    transition: reduced\n      ? \"opacity 120ms ease-out\"\n      : shown\n        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`\n        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,\n  };\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/hover-card. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}