{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/hover-card/hover-card.tsx",
      "type": "registry:component",
      "target": "@components/xzero/hover-card/hover-card.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } 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 styles from \"./hover-card.module.css\";\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) { listeners.add(listener); return () => { listeners.delete(listener); }; },\n  get: () => snapshot,\n  server: () => IDLE,\n  warm: () => snapshot.id !== null || performance.now() - closedAt < SKIP_WINDOW,\n  open(id: string) { if (snapshot.id === id) return; snapshot = { id, instant: cards.warm() }; listeners.forEach(listener => listener()); },\n  close(id: string) { if (snapshot.id !== id) return; snapshot = IDLE; closedAt = performance.now(); listeners.forEach(listener => listener()); },\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<HTMLAttributes<HTMLDivElement>, \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"> { instant: boolean; reduced: boolean }\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({ instant, reduced, className, style, children, ...props }, forwardedRef) {\n  const [isPresent, safeToRemove] = usePresence();\n  const node = useRef<HTMLDivElement | null>(null);\n  const progress = useMotionValue(0);\n  const x = useMotionValue(0), y = useMotionValue(0), scale = useMotionValue(1), 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((element: HTMLDivElement | null) => {\n    node.current = element;\n    if (typeof forwardedRef === \"function\") forwardedRef(element); else if (forwardedRef) forwardedRef.current = element;\n  }, [forwardedRef]);\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 : .96 + .04 * value);\n    };\n    const unsubscribe = progress.on(\"change\", apply);\n    apply(progress.get());\n    const controls = isPresent\n      ? animate(progress, 1, still ? { duration: .14, ease: enter } : motionTokens.spring.smooth)\n      : animate(progress, 0, { duration: .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 ? .15 : still ? .09 : .16, ease: enter })\n      : animate(opacity, 0, { duration: reduced ? .1 : .12, ease: standard });\n    if (!isPresent) fade.then(() => { if (!cancelled) safeToRemove?.(); });\n    return () => { cancelled = true; unsubscribe(); controls.stop(); fade.stop(); };\n  }, [isPresent, still, reduced, progress, x, y, scale, opacity, safeToRemove]);\n\n  return <motion.div {...props} ref={setRef} className={[styles.card, className].filter(Boolean).join(\" \")} style={{ ...style, x, y, scale, opacity }}>\n    {children}\n  </motion.div>;\n});\n\nexport function HoverCard({ children, content, side = \"bottom\", align = \"start\", openDelay = OPEN_DELAY, closeDelay = CLOSE_DELAY, className }: 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 () => { window.clearTimeout(pending.open); window.clearTimeout(pending.close); cards.close(id); };\n  }, [id]);\n\n  const clearTimers = () => { window.clearTimeout(timers.current.open); window.clearTimeout(timers.current.close); };\n  const show = () => { clearTimers(); dismissed.current = false; cards.open(id); };\n  const hide = () => { clearTimers(); cards.close(id); };\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 <PopoverPrimitive.Root open={open} onOpenChange={next => { if (!next) hide(); }}>\n    <PopoverPrimitive.Anchor asChild ref={triggerRef} aria-describedby={open ? cardId : undefined} data-hover-card={open ? \"open\" : \"closed\"}\n      onPointerEnter={(event: PointerEvent) => { if (!mouse(event)) return; inside.current.trigger = true; scheduleOpen(); }}\n      onPointerLeave={(event: PointerEvent) => { if (!mouse(event)) return; inside.current.trigger = false; dismissed.current = false; scheduleClose(); }}\n      onPointerDown={(event: PointerEvent) => { pointerType.current = event.pointerType; }}\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\") { if (isOpen) hide(); else show(); } else if (!isOpen) show();\n        pointerType.current = \"\";\n      }}\n      onFocus={(event: FocusEvent<HTMLElement>) => { if (!event.currentTarget.matches(\":focus-visible\")) return; inside.current.focus = true; show(); }}\n      onBlur={() => { inside.current.focus = false; dismissed.current = false; scheduleClose(); }}>\n      {children}\n    </PopoverPrimitive.Anchor>\n    <AnimatePresence>\n      {open && <PopoverPrimitive.Portal forceMount key=\"card\">\n        <PopoverPrimitive.Content forceMount asChild id={cardId} role=\"tooltip\" side={side} align={align} sideOffset={8} collisionPadding={12}\n          onOpenAutoFocus={event => event.preventDefault()} onCloseAutoFocus={event => event.preventDefault()}\n          onEscapeKeyDown={() => { dismissed.current = true; }}\n          onInteractOutside={event => { if (triggerRef.current?.contains(event.target as Node)) event.preventDefault(); }}\n          onPointerEnter={(event: PointerEvent) => { if (!mouse(event)) return; inside.current.card = true; window.clearTimeout(timers.current.close); }}\n          onPointerLeave={(event: PointerEvent) => { if (!mouse(event)) return; inside.current.card = false; scheduleClose(); }}>\n          <Surface instant={state.instant} reduced={reduced} className={className}>{content}</Surface>\n        </PopoverPrimitive.Content>\n      </PopoverPrimitive.Portal>}\n    </AnimatePresence>\n  </PopoverPrimitive.Root>;\n}\n\nexport interface HoverCardStat { label: string; value: string | number }\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(() => { const node = image.current; if (node && !node.complete) node.dataset.loading = \"\"; }, [src]);\n  // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic; pass next/image through `avatar` instead.\n  return <img ref={image} src={src} alt=\"\" width={48} height={48} decoding=\"async\" onLoad={event => { delete event.currentTarget.dataset.loading; }} />;\n}\n\nexport function HoverCardProfile({ name, role, avatar, bio, stats = [], meta }: HoverCardProfileProps) {\n  const reduced = useReducedMotion() ?? false;\n  const initials = name.trim().split(/\\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join(\"\");\n  const settle = (order: number) => reduced ? {} : { initial: { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, transition: { duration: motionTokens.duration.standard, ease: enter, delay: .04 + order * motionTokens.stagger.item } };\n  return <div className={styles.profile}>\n    <motion.div className={styles.head} {...settle(0)}>\n      <span className={styles.avatar} aria-hidden=\"true\">{typeof avatar === \"string\" ? <Portrait src={avatar} /> : avatar ?? <span className={styles.initials}>{initials}</span>}</span>\n      <span className={styles.identity}><span className={styles.name}>{name}</span>{role && <span className={styles.role}>{role}</span>}</span>\n    </motion.div>\n    {bio && <motion.p className={styles.bio} {...settle(1)}>{bio}</motion.p>}\n    {stats.length > 0 && <motion.dl className={styles.stats} {...settle(2)}>\n      {stats.map(stat => <div key={stat.label} className={styles.stat}><dt>{stat.label}</dt><dd>{stat.value}</dd></div>)}\n    </motion.dl>}\n    {meta && <motion.div className={styles.meta} {...settle(3)}>{meta}</motion.div>}\n  </div>;\n}\n\nexport default HoverCard;\n"
    },
    {
      "path": "registry/components/hover-card/hover-card.module.css",
      "type": "registry:component",
      "target": "@components/xzero/hover-card/hover-card.module.css",
      "content": "/* A floating layer, so it earns the floating shadow. Transforms pivot on the placed origin, the corner nearest the trigger. */\n.card { z-index: 60; box-sizing: border-box; width: 18.5rem; max-width: min(18.5rem, var(--radix-popover-content-available-width, calc(100vw - 24px))); padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); font-family: var(--font-body); letter-spacing: var(--tracking-body); transform-origin: var(--radix-popover-content-transform-origin); outline: none; }\n.profile { display: grid; gap: var(--space-3); }\n.head { display: flex; min-width: 0; align-items: center; gap: var(--space-3); }\n.avatar { position: relative; display: grid; width: 48px; height: 48px; flex: none; place-items: center; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; }\n.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; transition: opacity var(--duration-standard) var(--ease-enter), filter var(--duration-standard) var(--ease-enter); }\n.avatar img[data-loading] { opacity: 0; filter: blur(4px); transition: none; }\n.identity { display: grid; min-width: 0; }\n.name, .role { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.name { color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }\n.role { color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n.bio { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n/* Numbers lead and labels follow, while the markup keeps label then value for screen readers. */\n.stats { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); margin: 0; padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }\n.stat { display: flex; flex-direction: column-reverse; }\n.stat dt { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n.stat dd { margin: 0; color: var(--foreground); font-size: var(--text-base); font-variant-numeric: tabular-nums; font-weight: 500; line-height: var(--leading-body); }\n.meta { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n.meta svg { flex: none; }\n@media (prefers-reduced-motion: reduce) { .avatar img { transition: none; } .avatar img[data-loading] { filter: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/hover-card",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "preview",
      "people",
      "popover"
    ],
    "docs": "https://ui.xzero.co.uk/components/hover-card",
    "markdown": "https://ui.xzero.co.uk/components/hover-card/markdown"
  }
}