{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A profile hover card: avatar/handle trigger that expands into a preview with stats, bio and a follow action.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { BadgeCheck } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { FocusEvent, KeyboardEvent, ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nconst DEFAULT_OPEN_DELAY = 350;\nconst DEFAULT_CLOSE_DELAY = 150;\nconst CARD_MIN_SPACE_PX = 260;\nconst COUNT_UP_DURATION_MS = 600;\nconst SPRING_TRANSITION = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\n\nexport type SocialProfileStat = {\n  label: string;\n  value: number | string;\n};\n\nexport type SocialProfile = {\n  avatar: string;\n  banner?: string;\n  bio?: string;\n  handle: string;\n  id: string;\n  name: string;\n  stats?: SocialProfileStat[];\n  url?: string;\n  verified?: boolean;\n};\n\nexport type SocialHoverCardPlacement = \"auto\" | \"bottom\" | \"top\";\n\nexport type SocialHoverCardProps = {\n  className?: string;\n  closeDelay?: number;\n  following?: boolean;\n  loading?: boolean;\n  onFollowChange?: (following: boolean) => void;\n  openDelay?: number;\n  placement?: SocialHoverCardPlacement;\n  profile: SocialProfile;\n  trigger?: ReactNode;\n};\n\nconst easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3;\n\nconst CountUpValue = ({\n  value,\n  shouldAnimate,\n}: {\n  shouldAnimate: boolean;\n  value: number;\n}) => {\n  const [display, setDisplay] = useState(shouldAnimate ? 0 : value);\n\n  useEffect(() => {\n    if (!shouldAnimate) {\n      setDisplay(value);\n      return;\n    }\n\n    let frameId = 0;\n    const start = performance.now();\n\n    const tick = (now: number) => {\n      const elapsed = now - start;\n      const progress = Math.min(elapsed / COUNT_UP_DURATION_MS, 1);\n      setDisplay(Math.round(value * easeOutCubic(progress)));\n      if (progress < 1) {\n        frameId = requestAnimationFrame(tick);\n      }\n    };\n\n    frameId = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frameId);\n  }, [value, shouldAnimate]);\n\n  return <span>{display.toLocaleString()}</span>;\n};\n\nconst StatValue = ({\n  stat,\n  shouldAnimate,\n}: {\n  shouldAnimate: boolean;\n  stat: SocialProfileStat;\n}) => {\n  if (typeof stat.value === \"number\") {\n    return <CountUpValue shouldAnimate={shouldAnimate} value={stat.value} />;\n  }\n  return <span>{stat.value}</span>;\n};\n\nconst CardSkeleton = () => (\n  <div aria-hidden=\"true\" className=\"animate-pulse p-4\">\n    <div className=\"h-16 rounded-lg bg-foreground/10\" />\n    <div className=\"mt-3 flex items-center gap-3\">\n      <div className=\"size-12 rounded-full bg-foreground/10\" />\n      <div className=\"flex-1 space-y-2\">\n        <div className=\"h-3 w-2/3 rounded bg-foreground/10\" />\n        <div className=\"h-3 w-1/3 rounded bg-foreground/10\" />\n      </div>\n    </div>\n    <div className=\"mt-4 h-3 w-full rounded bg-foreground/10\" />\n    <div className=\"mt-2 h-3 w-4/5 rounded bg-foreground/10\" />\n  </div>\n);\n\nconst SocialHoverCard = ({\n  profile,\n  trigger,\n  openDelay = DEFAULT_OPEN_DELAY,\n  closeDelay = DEFAULT_CLOSE_DELAY,\n  placement = \"auto\",\n  following,\n  onFollowChange,\n  loading = false,\n  className,\n}: SocialHoverCardProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [isOpen, setIsOpen] = useState(false);\n  const [openedVia, setOpenedVia] = useState<\"focus\" | \"hover\">(\"hover\");\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n  const [resolvedPlacement, setResolvedPlacement] = useState<\"bottom\" | \"top\">(\n    placement === \"top\" ? \"top\" : \"bottom\"\n  );\n  const [internalFollowing, setInternalFollowing] = useState(\n    following ?? false\n  );\n\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const openTimeoutRef = useRef<number | undefined>(undefined);\n  const closeTimeoutRef = useRef<number | undefined>(undefined);\n\n  const isFollowingControlled = following !== undefined;\n  const isFollowing = isFollowingControlled ? following : internalFollowing;\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverDevice(mediaQuery.matches);\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverDevice(event.matches);\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => {\n      mediaQuery.removeEventListener(\"change\", handleChange);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!(isOpen && placement === \"auto\" && triggerRef.current)) {\n      return;\n    }\n    const rect = triggerRef.current.getBoundingClientRect();\n    const spaceBelow = window.innerHeight - rect.bottom;\n    const spaceAbove = rect.top;\n    setResolvedPlacement(\n      spaceBelow < CARD_MIN_SPACE_PX && spaceAbove > spaceBelow\n        ? \"top\"\n        : \"bottom\"\n    );\n  }, [isOpen, placement]);\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(openTimeoutRef.current);\n      window.clearTimeout(closeTimeoutRef.current);\n    },\n    []\n  );\n\n  const clearTimers = () => {\n    window.clearTimeout(openTimeoutRef.current);\n    window.clearTimeout(closeTimeoutRef.current);\n  };\n\n  const openCard = (via: \"focus\" | \"hover\") => {\n    clearTimers();\n    setOpenedVia(via);\n    if (via === \"hover\") {\n      openTimeoutRef.current = window.setTimeout(() => {\n        setIsOpen(true);\n      }, openDelay);\n    } else {\n      setIsOpen(true);\n    }\n  };\n\n  const closeCard = (immediate = false) => {\n    clearTimers();\n    if (immediate) {\n      setIsOpen(false);\n      return;\n    }\n    closeTimeoutRef.current = window.setTimeout(() => {\n      setIsOpen(false);\n    }, closeDelay);\n  };\n\n  const handleTriggerClick = () => {\n    if (isHoverDevice) {\n      return;\n    }\n    if (isOpen) {\n      closeCard(true);\n    } else {\n      openCard(\"focus\");\n    }\n  };\n\n  const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"Escape\" && isOpen) {\n      event.preventDefault();\n      closeCard(true);\n      triggerRef.current?.focus();\n    }\n  };\n\n  const handleWrapperBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (!wrapperRef.current?.contains(event.relatedTarget as Node | null)) {\n      closeCard(true);\n    }\n  };\n\n  const toggleFollow = () => {\n    const next = !isFollowing;\n    if (!isFollowingControlled) {\n      setInternalFollowing(next);\n    }\n    onFollowChange?.(next);\n  };\n\n  const transformOrigin =\n    resolvedPlacement === \"top\" ? \"bottom center\" : \"top center\";\n  const cardId = `social-hover-card-${profile.id}`;\n\n  return (\n    <div\n      className=\"relative inline-block\"\n      onBlur={handleWrapperBlur}\n      onMouseEnter={() => isHoverDevice && openCard(\"hover\")}\n      onMouseLeave={() => isHoverDevice && closeCard()}\n      ref={wrapperRef}\n    >\n      <button\n        aria-expanded={isOpen}\n        aria-haspopup=\"dialog\"\n        className={cn(\n          \"rounded font-medium text-brand text-sm underline-offset-2 hover:underline\",\n          className\n        )}\n        onClick={handleTriggerClick}\n        onFocus={() => openCard(\"focus\")}\n        onKeyDown={handleTriggerKeyDown}\n        ref={triggerRef}\n        type=\"button\"\n      >\n        {trigger ?? `@${profile.handle}`}\n      </button>\n\n      <AnimatePresence>\n        {isOpen ? (\n          <motion.div\n            animate={{ opacity: 1, scale: 1 }}\n            aria-label={\n              openedVia === \"focus\"\n                ? `${profile.name} profile preview`\n                : undefined\n            }\n            className={cn(\n              \"absolute left-1/2 z-30 w-80 -translate-x-1/2 overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-xl\",\n              resolvedPlacement === \"top\" ? \"bottom-full mb-2\" : \"top-full mt-2\"\n            )}\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, scale: 0.92 }\n            }\n            id={cardId}\n            initial={\n              shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.92 }\n            }\n            onMouseEnter={() => isHoverDevice && clearTimers()}\n            onMouseLeave={() => isHoverDevice && closeCard()}\n            role={openedVia === \"focus\" ? \"dialog\" : undefined}\n            style={{ transformOrigin }}\n            transition={\n              shouldReduceMotion ? { duration: 0 } : SPRING_TRANSITION\n            }\n          >\n            {loading ? (\n              <CardSkeleton />\n            ) : (\n              <div>\n                {profile.banner ? (\n                  <img\n                    alt=\"\"\n                    className=\"h-16 w-full object-cover\"\n                    height={64}\n                    src={profile.banner}\n                    width={320}\n                  />\n                ) : (\n                  <div className=\"h-16 w-full bg-gradient-to-r from-brand/40 to-brand/10\" />\n                )}\n                <div className=\"p-4\">\n                  <div className=\"flex items-start justify-between gap-3\">\n                    <div className=\"flex items-center gap-3\">\n                      <img\n                        alt={profile.name}\n                        className=\"size-12 shrink-0 rounded-full border-2 border-background object-cover\"\n                        height={48}\n                        src={profile.avatar}\n                        width={48}\n                      />\n                      <div className=\"min-w-0\">\n                        <div className=\"flex items-center gap-1\">\n                          {profile.url ? (\n                            <a\n                              className=\"truncate font-semibold text-foreground text-sm hover:underline\"\n                              href={profile.url}\n                              rel=\"noopener noreferrer\"\n                              target=\"_blank\"\n                            >\n                              {profile.name}\n                            </a>\n                          ) : (\n                            <p className=\"truncate font-semibold text-foreground text-sm\">\n                              {profile.name}\n                            </p>\n                          )}\n                          {profile.verified ? (\n                            <BadgeCheck\n                              aria-label=\"Verified account\"\n                              className=\"size-4 shrink-0 text-brand\"\n                            />\n                          ) : null}\n                        </div>\n                        <p className=\"truncate text-muted-foreground text-xs\">\n                          @{profile.handle}\n                        </p>\n                      </div>\n                    </div>\n                    <SmoothButton\n                      asChild\n                      color={isFollowing ? \"neutral\" : \"accent\"}\n                      shape=\"pill\"\n                      size=\"xs\"\n                      variant={isFollowing ? \"soft\" : \"solid\"}\n                    >\n                      <motion.button\n                        aria-pressed={isFollowing}\n                        className=\"shrink-0\"\n                        layout\n                        onClick={toggleFollow}\n                        transition={\n                          shouldReduceMotion\n                            ? { duration: 0 }\n                            : SPRING_TRANSITION\n                        }\n                        type=\"button\"\n                      >\n                        <AnimatePresence initial={false} mode=\"wait\">\n                          <motion.span\n                            animate={{ opacity: 1 }}\n                            className=\"inline-block\"\n                            exit={{ opacity: 0 }}\n                            initial={{ opacity: 0 }}\n                            key={isFollowing ? \"following\" : \"follow\"}\n                            transition={\n                              shouldReduceMotion\n                                ? { duration: 0 }\n                                : { duration: 0.15 }\n                            }\n                          >\n                            {isFollowing ? \"Following\" : \"Follow\"}\n                          </motion.span>\n                        </AnimatePresence>\n                      </motion.button>\n                    </SmoothButton>\n                  </div>\n\n                  {profile.bio ? (\n                    <p className=\"mt-3 text-foreground/80 text-sm leading-relaxed\">\n                      {profile.bio}\n                    </p>\n                  ) : null}\n\n                  {profile.stats?.length ? (\n                    <ul className=\"mt-4 flex items-center gap-4\">\n                      {profile.stats.map((stat) => (\n                        <li className=\"flex flex-col\" key={stat.label}>\n                          <span className=\"font-semibold text-foreground text-sm\">\n                            <StatValue\n                              shouldAnimate={!shouldReduceMotion}\n                              stat={stat}\n                            />\n                          </span>\n                          <span className=\"text-muted-foreground text-xs\">\n                            {stat.label}\n                          </span>\n                        </li>\n                      ))}\n                    </ul>\n                  ) : null}\n                </div>\n              </div>\n            )}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n};\n\nexport default SocialHoverCard;\n","path":"index.tsx","target":"components/smoothui/social-hover-card/index.tsx","type":"registry:ui"}],"name":"social-hover-card","registryDependencies":["https://smoothui.dev/r/smooth-button.json","https://smoothui.dev/r/tokens.json"],"title":"Social Hover Card","type":"registry:ui"}