{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-card",
  "type": "registry:ui",
  "title": "Hover card",
  "description": "Preview a person or link on hover or focus without leaving the page.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "@radix-ui/react-popover",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/hover-card/hover-card.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type { FocusEvent, HTMLAttributes, PointerEvent, ReactElement, ReactNode } from \"react\";\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport { AnimatePresence, animate, motion, useMotionValue, usePresence, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nconst OPEN_DELAY = 500;\nconst CLOSE_DELAY = 140;\n/** Moving to another trigger within this window opens its card almost at once. */\nconst SKIP_WINDOW = 300;\n/** Even warm, a trigger waits this long, so a pointer crossing it on the way into an open card does not steal the card. */\nconst INTENT = 80;\n/** How far the card starts toward its trigger, in px. */\nconst TRAVEL = 4;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n\ntype Snapshot = { id: string | null; instant: boolean };\nconst IDLE: Snapshot = { id: null, instant: false };\nlet snapshot = IDLE;\nlet closedAt = -Infinity;\nconst listeners = new Set<() => void>();\n/** One hover card is open at a time on the page. While one is open, or just after it closes, the next one opens without waiting and only fades. */\nconst cards = {\n  subscribe: (listener: () => void) => {\n    listeners.add(listener);\n    return () => {\n      listeners.delete(listener);\n    };\n  },\n  get: () => snapshot,\n  server: () => IDLE,\n  warm: () => snapshot.id !== null || performance.now() - closedAt < SKIP_WINDOW,\n  open: (id: string) => {\n    if (snapshot.id === id) return;\n    snapshot = { id, instant: cards.warm() };\n    listeners.forEach((listener) => listener());\n  },\n  close: (id: string) => {\n    if (snapshot.id !== id) return;\n    snapshot = IDLE;\n    closedAt = performance.now();\n    listeners.forEach((listener) => listener());\n  },\n};\n\n/**\n * A rich preview of a person or link that opens after a short hover or keyboard focus, for mentions, avatars, and references where a click should stay free for navigation.\n * It waits half a second, opens almost at once when moving between triggers, stays open while the pointer travels into it, and closes fast on leave or Escape.\n * On touch, a tap toggles it. Keep the content read only; anything actionable belongs behind the trigger itself.\n */\nexport interface HoverCardProps {\n  /** The trigger, such as a mention button or a link. It must accept a ref and be focusable. */\n  children: ReactElement;\n  /** The preview. `HoverCardProfile` covers people; any read only content works. */\n  content: ReactNode;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Milliseconds of hover before the first card opens. */\n  openDelay?: number;\n  /** Milliseconds of grace after the pointer leaves, so it can travel into the card. */\n  closeDelay?: number;\n  className?: string;\n}\n\ninterface SurfaceProps extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n> {\n  instant: boolean;\n  reduced: boolean;\n}\n\n/** Grows from the trigger edge: scale from .96 around the placed origin and a 4px offset toward the trigger on one smooth spring, with its own fade.\n *  Both retarget from where they are if the pointer comes back mid-exit, so the card turns around instead of jumping. */\nconst Surface = forwardRef<HTMLDivElement, SurfaceProps>(function Surface(\n  { instant, reduced, className, style, children, ...props },\n  forwardedRef,\n) {\n  const [isPresent, safeToRemove] = usePresence();\n  const node = useRef<HTMLDivElement | null>(null);\n  const progress = useMotionValue(0);\n  const x = useMotionValue(0),\n    y = useMotionValue(0),\n    scale = useMotionValue(1),\n    opacity = useMotionValue(0);\n  // Decided when the card mounts: a card revived mid-exit keeps the path it came in on, even though the page is now warm.\n  const [still] = useState(() => instant || reduced);\n  const setRef = useCallback(\n    (element: HTMLDivElement | null) => {\n      node.current = element;\n      if (typeof forwardedRef === \"function\") forwardedRef(element);\n      else if (forwardedRef) forwardedRef.current = element;\n    },\n    [forwardedRef],\n  );\n\n  useEffect(() => {\n    let cancelled = false;\n    // The side is read from the placed card every frame, so a card that flips above its trigger still grows from the trigger edge.\n    const apply = (value: number) => {\n      const side = node.current?.dataset.side;\n      const travel = still ? 0 : (1 - value) * TRAVEL;\n      x.set(side === \"left\" ? travel : side === \"right\" ? -travel : 0);\n      y.set(side === \"top\" ? travel : side === \"bottom\" ? -travel : 0);\n      scale.set(still ? 1 : 0.96 + 0.04 * value);\n    };\n    const unsubscribe = progress.on(\"change\", apply);\n    apply(progress.get());\n    const controls = isPresent\n      ? animate(progress, 1, still ? { duration: 0.14, ease: enter } : motionTokens.spring.smooth)\n      : animate(progress, 0, { duration: 0.12, ease: standard });\n    // The fade leads the grow on the way in and runs alongside it on the way out; it starts from the current opacity either way.\n    const fade = isPresent\n      ? animate(opacity, 1, { duration: reduced ? 0.15 : still ? 0.09 : 0.16, ease: enter })\n      : animate(opacity, 0, { duration: reduced ? 0.1 : 0.12, ease: standard });\n    if (!isPresent)\n      void fade.then(() => {\n        if (!cancelled) safeToRemove?.();\n      });\n    return () => {\n      cancelled = true;\n      unsubscribe();\n      controls.stop();\n      fade.stop();\n    };\n  }, [isPresent, still, reduced, progress, x, y, scale, opacity, safeToRemove]);\n\n  // A floating layer, so it earns the floating shadow. Transforms pivot on the placed origin, the corner nearest the trigger.\n  return (\n    <motion.div\n      {...props}\n      ref={setRef}\n      className={cn(\n        \"z-60 box-border w-[18.5rem] max-w-[min(18.5rem,var(--radix-popover-content-available-width,calc(100vw_-_24px)))] origin-(--radix-popover-content-transform-origin) rounded-panel border border-line bg-surface p-4 font-body tracking-body text-ink shadow-floating outline-none\",\n        className,\n      )}\n      style={{ ...style, x, y, scale, opacity }}\n    >\n      {children}\n    </motion.div>\n  );\n});\n\nexport function HoverCard({\n  children,\n  content,\n  side = \"bottom\",\n  align = \"start\",\n  openDelay = OPEN_DELAY,\n  closeDelay = CLOSE_DELAY,\n  className,\n}: HoverCardProps) {\n  const id = useId();\n  const cardId = `${id}-card`;\n  const state = useSyncExternalStore(cards.subscribe, cards.get, cards.server);\n  const open = state.id === id;\n  const reduced = useReducedMotion() ?? false;\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const timers = useRef({ open: 0, close: 0 });\n  const inside = useRef({ trigger: false, card: false, focus: false });\n  /** Set by Escape, so a pointer resting on the trigger does not reopen what was just dismissed. */\n  const dismissed = useRef(false);\n  const pointerType = useRef(\"\");\n\n  useEffect(() => {\n    const pending = timers.current;\n    return () => {\n      window.clearTimeout(pending.open);\n      window.clearTimeout(pending.close);\n      cards.close(id);\n    };\n  }, [id]);\n\n  const clearTimers = () => {\n    window.clearTimeout(timers.current.open);\n    window.clearTimeout(timers.current.close);\n  };\n  const show = () => {\n    clearTimers();\n    dismissed.current = false;\n    cards.open(id);\n  };\n  const hide = () => {\n    clearTimers();\n    cards.close(id);\n  };\n  const scheduleOpen = () => {\n    window.clearTimeout(timers.current.close);\n    if (dismissed.current || cards.get().id === id) return;\n    window.clearTimeout(timers.current.open);\n    timers.current.open = window.setTimeout(() => cards.open(id), cards.warm() ? INTENT : openDelay);\n  };\n  const scheduleClose = () => {\n    window.clearTimeout(timers.current.open);\n    const { trigger, card, focus } = inside.current;\n    if (trigger || card || focus) return;\n    window.clearTimeout(timers.current.close);\n    timers.current.close = window.setTimeout(() => cards.close(id), closeDelay);\n  };\n  const mouse = (event: PointerEvent) => event.pointerType !== \"touch\";\n\n  return (\n    <PopoverPrimitive.Root\n      open={open}\n      onOpenChange={(next) => {\n        if (!next) hide();\n      }}\n    >\n      <PopoverPrimitive.Anchor\n        asChild\n        ref={triggerRef}\n        aria-describedby={open ? cardId : undefined}\n        data-hover-card={open ? \"open\" : \"closed\"}\n        onPointerEnter={(event: PointerEvent) => {\n          if (!mouse(event)) return;\n          inside.current.trigger = true;\n          scheduleOpen();\n        }}\n        onPointerLeave={(event: PointerEvent) => {\n          if (!mouse(event)) return;\n          inside.current.trigger = false;\n          dismissed.current = false;\n          scheduleClose();\n        }}\n        onPointerDown={(event: PointerEvent) => {\n          pointerType.current = event.pointerType;\n        }}\n        onClick={() => {\n          // A tap toggles the card on touch; a click or Enter opens it without the wait.\n          const isOpen = cards.get().id === id;\n          if (pointerType.current === \"touch\") {\n            if (isOpen) hide();\n            else show();\n          } else if (!isOpen) show();\n          pointerType.current = \"\";\n        }}\n        onFocus={(event: FocusEvent<HTMLElement>) => {\n          if (!event.currentTarget.matches(\":focus-visible\")) return;\n          inside.current.focus = true;\n          show();\n        }}\n        onBlur={() => {\n          inside.current.focus = false;\n          dismissed.current = false;\n          scheduleClose();\n        }}\n      >\n        {children}\n      </PopoverPrimitive.Anchor>\n      <AnimatePresence>\n        {open && (\n          <PopoverPrimitive.Portal forceMount key=\"card\">\n            <PopoverPrimitive.Content\n              forceMount\n              asChild\n              id={cardId}\n              role=\"tooltip\"\n              side={side}\n              align={align}\n              sideOffset={8}\n              collisionPadding={12}\n              onOpenAutoFocus={(event) => event.preventDefault()}\n              onCloseAutoFocus={(event) => event.preventDefault()}\n              onEscapeKeyDown={() => {\n                dismissed.current = true;\n              }}\n              onInteractOutside={(event) => {\n                if (triggerRef.current?.contains(event.target as Node)) event.preventDefault();\n              }}\n              onPointerEnter={(event: PointerEvent) => {\n                if (!mouse(event)) return;\n                inside.current.card = true;\n                window.clearTimeout(timers.current.close);\n              }}\n              onPointerLeave={(event: PointerEvent) => {\n                if (!mouse(event)) return;\n                inside.current.card = false;\n                scheduleClose();\n              }}\n            >\n              <Surface instant={state.instant} reduced={reduced} className={className}>\n                {content}\n              </Surface>\n            </PopoverPrimitive.Content>\n          </PopoverPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </PopoverPrimitive.Root>\n  );\n}\n\nexport interface HoverCardStat {\n  label: string;\n  value: string | number;\n}\n\n/** A person preview for `HoverCard`: portrait, name, role, a short bio, a couple of numbers, and a quiet footer line. Its rows settle in with a tiny stagger. */\nexport interface HoverCardProfileProps {\n  name: string;\n  /** Role and team, such as \"Product designer, Payments\". */\n  role?: string;\n  /** A portrait URL, or your own image element such as next/image. Initials show when it is missing. */\n  avatar?: string | ReactNode;\n  bio?: string;\n  stats?: HoverCardStat[];\n  /** A footer line, such as a location and local time. */\n  meta?: ReactNode;\n}\n\nfunction Portrait({ src }: { src: string }) {\n  const image = useRef<HTMLImageElement>(null);\n  // A cached portrait shows at once; one still downloading fades in from a soft blur.\n  useLayoutEffect(() => {\n    const node = image.current;\n    if (node && !node.complete) node.dataset.loading = \"\";\n  }, [src]);\n  return (\n    // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic; pass next/image through `avatar` instead.\n    <img\n      ref={image}\n      src={src}\n      alt=\"\"\n      width={48}\n      height={48}\n      decoding=\"async\"\n      onLoad={(event) => {\n        delete event.currentTarget.dataset.loading;\n      }}\n    />\n  );\n}\n\n/** The portrait circle. Any image inside it (a URL or your own element) fills it; one still downloading fades in from a soft blur. */\nconst avatarClass =\n  \"relative grid size-12 flex-none place-items-center overflow-hidden rounded-full border border-line bg-hover text-(length:--text-sm) font-medium text-ink [&_img]:block [&_img]:size-full [&_img]:object-cover [&_img]:transition-[opacity,filter] [&_img]:duration-standard [&_img]:ease-enter [&_img[data-loading]]:[filter:blur(4px)] [&_img[data-loading]]:opacity-0 [&_img[data-loading]]:transition-none motion-reduce:[&_img]:transition-none motion-reduce:[&_img[data-loading]]:[filter:none]\";\n\nexport function HoverCardProfile({ name, role, avatar, bio, stats = [], meta }: HoverCardProfileProps) {\n  const reduced = useReducedMotion() ?? false;\n  const initials = name\n    .trim()\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((part) => part[0]?.toUpperCase())\n    .join(\"\");\n  const settle = (order: number) =>\n    reduced\n      ? {}\n      : {\n          initial: { opacity: 0, y: 4 },\n          animate: { opacity: 1, y: 0 },\n          transition: {\n            duration: motionTokens.duration.standard,\n            ease: enter,\n            delay: 0.04 + order * motionTokens.stagger.item,\n          },\n        };\n  return (\n    <div className=\"grid gap-3\">\n      <motion.div className=\"flex min-w-0 items-center gap-3\" {...settle(0)}>\n        <span className={avatarClass} aria-hidden=\"true\">\n          {typeof avatar === \"string\" ? <Portrait src={avatar} /> : (avatar ?? <span>{initials}</span>)}\n        </span>\n        <span className=\"grid min-w-0\">\n          <span className=\"truncate text-base leading-body font-medium text-ink\">{name}</span>\n          {role && <span className=\"truncate text-sm leading-body text-ink-2\">{role}</span>}\n        </span>\n      </motion.div>\n      {bio && (\n        <motion.p className=\"m-0 text-sm leading-body text-ink-2\" {...settle(1)}>\n          {bio}\n        </motion.p>\n      )}\n      {/* Numbers lead and labels follow, while the markup keeps label then value for screen readers. */}\n      {stats.length > 0 && (\n        <motion.dl className=\"m-0 flex flex-wrap gap-x-6 gap-y-2 border-t border-line-soft pt-3\" {...settle(2)}>\n          {stats.map((stat) => (\n            <div key={stat.label} className=\"flex flex-col-reverse\">\n              <dt className=\"text-xs leading-body text-ink-3\">{stat.label}</dt>\n              <dd className=\"m-0 text-base leading-body font-medium text-ink tabular-nums\">{stat.value}</dd>\n            </div>\n          ))}\n        </motion.dl>\n      )}\n      {meta && (\n        <motion.div\n          className=\"flex items-center gap-1.5 text-xs leading-body text-ink-3 [&_svg]:flex-none\"\n          {...settle(3)}\n        >\n          {meta}\n        </motion.div>\n      )}\n    </div>\n  );\n}\n\nexport default HoverCard;\n",
      "type": "registry:component",
      "target": "@components/iris/hover-card/hover-card.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/hover-card",
  "meta": {
    "kind": "component",
    "group": "Overlays & disclosure",
    "export": "HoverCard",
    "tags": [
      "preview",
      "people",
      "popover"
    ],
    "docs": "https://irisui.dev/components/hover-card"
  }
}
