{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/hover-card/hover-card.tsx","type":"registry:component","target":"@components/arc/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, \"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(function Surface({ instant, reduced, className, style, children, ...props }, forwardedRef) {\n const [isPresent, safeToRemove] = usePresence();\n const node = useRef(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 \n {children}\n ;\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(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 { if (!next) hide(); }}>\n { 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) => { 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 \n \n {open && \n 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 {content}\n \n }\n \n ;\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(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 \"\" { 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
\n \n {typeof avatar === \"string\" ? : avatar ?? {initials}}\n {name}{role && {role}}\n \n {bio && {bio}}\n {stats.length > 0 && \n {stats.map(stat =>
{stat.label}
{stat.value}
)}\n
}\n {meta && {meta}}\n
;\n}\n\nexport default HoverCard;\n"},{"path":"registry/components/hover-card/hover-card.module.css","type":"registry:component","target":"@components/arc/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: 1.5; }\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://uiarc.dev/components/hover-card","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/hover-card","markdown":"https://uiarc.dev/components/hover-card/markdown","tags":["preview","people","popover"]}}