{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hover-card","type":"registry:component","title":"Hover card","description":"One card travels between names as you hover them.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/hover-card.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Profile = {\n  id: string;\n  name: string;\n  handle: string;\n  avatar: string;\n  bio: string;\n  followers: number;\n  following: number;\n};\n\n// Long enough that sweeping the pointer across a paragraph opens nothing.\nconst OPEN_DELAY = 500;\n// Covers the moment the pointer is between the link and the card, or\n// wobbles off an edge.\nconst CLOSE_GRACE = 150;\n// Just after a card closes, hovering another name skips the delay: the\n// reader is clearly browsing people, not passing through.\nconst SKIP_DELAY_FOR = 300;\nconst CARD_WIDTH = 300;\n// Close to the rendered height; only used to decide above or below.\nconst CARD_HEIGHT = 200;\nconst GAP = 8;\n// Keeps the card this far from the viewport edges.\nconst EDGE = 12;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\ntype Place = { side: \"top\" | \"bottom\"; left: number; originX: number };\ntype Custom = { instant: boolean; reduce: boolean };\n\n// Instant cards skip both the fade and the scale, per the tooltip rule.\n// Exits are faster than entrances so a closing card never holds the eye.\nconst CARD = {\n  hidden: ({ instant, reduce }: Custom) => ({\n    opacity: instant ? 1 : 0,\n    scale: instant || reduce ? 1 : 0.96,\n  }),\n  shown: ({ instant }: Custom) => ({\n    opacity: 1,\n    scale: 1,\n    transition: instant ? { duration: 0 } : { duration: 0.15, ease: EASE_OUT },\n  }),\n  exit: ({ instant, reduce }: Custom) => ({\n    opacity: 0,\n    scale: reduce ? 1 : 0.96,\n    transition: { duration: instant ? 0 : 0.1, ease: EASE_OUT },\n  }),\n};\n// `from` is where the previous card sat on screen when this one replaced it:\n// the new card starts there and travels to its own name.\ntype OpenState = {\n  id: string;\n  place: Place;\n  instant: boolean;\n  from?: DOMRect;\n} | null;\n\n// The trip between two names. 260ms on the iOS drawer curve: long enough to\n// follow a card crossing a paragraph, and it decelerates onto the new name\n// so the eye lands with it.\nconst TRAVEL = { duration: 260, easing: \"cubic-bezier(0.32, 0.72, 0, 1)\" };\n// The contents re-develop on arrival instead of swapping in place.\nconst DEVELOP = { duration: 220, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\" };\n\ntype Group = {\n  open: OpenState;\n  // Registers the card on screen, so the next one knows where to start.\n  setCard: (el: HTMLElement) => void;\n  requestOpen: (id: string, measure: () => Place, immediate?: boolean) => void;\n  requestClose: (id: string) => void;\n  closeNow: () => void;\n};\n\nconst GroupContext = createContext<Group | null>(null);\n\nfunction measurePlace(el: HTMLElement): Place {\n  const rect = el.getBoundingClientRect();\n  const below = window.innerHeight - rect.bottom;\n  const side =\n    below >= CARD_HEIGHT + GAP + EDGE || below >= rect.top ? \"bottom\" : \"top\";\n  const width = Math.min(CARD_WIDTH, window.innerWidth - EDGE * 2);\n  const centred = rect.left + rect.width / 2 - width / 2;\n  const clamped = Math.min(\n    Math.max(centred, EDGE),\n    window.innerWidth - EDGE - width,\n  );\n  return {\n    side,\n    left: clamped - rect.left,\n    // Scales out of the link itself, even when the card is pushed sideways.\n    originX: rect.left + rect.width / 2 - clamped,\n  };\n}\n\n// Shares one open card between every mention inside it, which is what lets\n// a second name open instantly while a first card is showing.\nexport function HoverCardGroup({ children }: { children: React.ReactNode }) {\n  const [open, setOpen] = useState<OpenState>(null);\n  const openRef = useRef<OpenState>(null);\n  const openTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const closedAt = useRef(-Infinity);\n  const card = useRef<HTMLElement | null>(null);\n  // Where the last card was when it closed, for a quick return trip.\n  const lastRect = useRef<DOMRect | null>(null);\n\n  useEffect(\n    () => () => {\n      clearTimeout(openTimer.current);\n      clearTimeout(closeTimer.current);\n    },\n    [],\n  );\n\n  const cardRect = () =>\n    card.current?.isConnected\n      ? card.current.getBoundingClientRect()\n      : undefined;\n\n  const commit = (next: OpenState) => {\n    if (openRef.current && !next) {\n      closedAt.current = performance.now();\n      lastRect.current = cardRect() ?? null;\n    }\n    openRef.current = next;\n    setOpen(next);\n  };\n\n  const group: Group = {\n    open,\n    setCard: (el) => {\n      card.current = el;\n    },\n    requestOpen: (id, measure, immediate) => {\n      clearTimeout(closeTimer.current);\n      const current = openRef.current;\n      if (current?.id === id) return;\n      clearTimeout(openTimer.current);\n      const recent = performance.now() - closedAt.current < SKIP_DELAY_FOR;\n      if (current) {\n        // Already showing someone: no delay, and the card itself moves over.\n        commit({ id, place: measure(), instant: true, from: cardRect() });\n      } else if (recent && lastRect.current) {\n        // Closed a moment ago on the way here: fly back out from there.\n        commit({ id, place: measure(), instant: true, from: lastRect.current });\n      } else if (immediate || recent) {\n        commit({ id, place: measure(), instant: false });\n      } else {\n        openTimer.current = setTimeout(\n          () => commit({ id, place: measure(), instant: false }),\n          OPEN_DELAY,\n        );\n      }\n    },\n    requestClose: (id) => {\n      clearTimeout(openTimer.current);\n      if (openRef.current?.id !== id) return;\n      clearTimeout(closeTimer.current);\n      closeTimer.current = setTimeout(() => commit(null), CLOSE_GRACE);\n    },\n    closeNow: () => {\n      clearTimeout(openTimer.current);\n      clearTimeout(closeTimer.current);\n      if (openRef.current) commit(null);\n    },\n  };\n\n  return <GroupContext value={group}>{children}</GroupContext>;\n}\n\nexport function Mention({ profile }: { profile: Profile }) {\n  const group = useContext(GroupContext);\n  if (!group) throw new Error(\"Mention must be inside a HoverCardGroup\");\n  const reduceMotion = useReducedMotion();\n  const cardId = useId();\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const lastPointer = useRef(\"mouse\");\n  const [following, setFollowing] = useState(false);\n\n  const state = group.open?.id === profile.id ? group.open : null;\n  const isOpen = state !== null;\n  // Held so the card can still be positioned while it animates out.\n  const [lastPlace, setLastPlace] = useState<Place | null>(null);\n  if (state && state.place !== lastPlace) setLastPlace(state.place);\n  const place = state?.place ?? lastPlace;\n\n  const measure = () => measurePlace(buttonRef.current!);\n  const cardRef = useRef<HTMLSpanElement>(null);\n  const contentRef = useRef<HTMLSpanElement>(null);\n  const from = state?.from;\n\n  // Plays the trip from the previous card's spot. Uses the independent\n  // translate property, so it never fights Motion's scale on transform.\n  useLayoutEffect(() => {\n    const el = cardRef.current;\n    if (!from || !el) return;\n    const to = el.getBoundingClientRect();\n    const dx = from.left - to.left;\n    const dy = from.top - to.top;\n    const animations: Animation[] = [];\n    if (!reduceMotion && (dx || dy)) {\n      animations.push(\n        el.animate({ translate: [`${dx}px ${dy}px`, \"0 0\"] }, TRAVEL),\n      );\n    }\n    animations.push(\n      contentRef.current!.animate(\n        reduceMotion\n          ? { opacity: [0, 1] }\n          : { opacity: [0, 1], filter: [\"blur(4px)\", \"blur(0px)\"] },\n        DEVELOP,\n      ),\n    );\n    return () => animations.forEach((a) => a.cancel());\n  }, [from, reduceMotion]);\n\n  // Taps have no hover, so a tap opens the card and a tap anywhere else\n  // closes it.\n  useEffect(() => {\n    if (!isOpen) return;\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) group.closeNow();\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [isOpen, group]);\n\n  const reduce = !!reduceMotion;\n  // Exits read this from AnimatePresence, since the exiting card itself is\n  // no longer re-rendered: instant when another card is taking its place.\n  const exitCustom: Custom = { instant: group.open !== null, reduce };\n  const cardCustom: Custom = { instant: state?.instant ?? false, reduce };\n\n  return (\n    <span\n      ref={rootRef}\n      className=\"relative inline-block\"\n      onPointerEnter={(e) => {\n        if (e.pointerType !== \"touch\") group.requestOpen(profile.id, measure);\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType !== \"touch\") group.requestClose(profile.id);\n      }}\n      // Keyboard focus only. A tap also focuses the button, and opening\n      // here would let the click that follows close it again.\n      onFocus={(e) => {\n        if (\n          e.target === buttonRef.current &&\n          e.target.matches(\":focus-visible\")\n        )\n          group.requestOpen(profile.id, measure, true);\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null))\n          group.requestClose(profile.id);\n      }}\n      onKeyDown={(e) => {\n        if (e.key !== \"Escape\" || !isOpen) return;\n        e.stopPropagation();\n        group.closeNow();\n        buttonRef.current?.focus();\n      }}\n    >\n      <button\n        ref={buttonRef}\n        type=\"button\"\n        aria-expanded={isOpen}\n        aria-controls={isOpen ? cardId : undefined}\n        onPointerDown={(e) => {\n          lastPointer.current = e.pointerType;\n        }}\n        onClick={(e) => {\n          // A mouse has already opened it by hovering; taps and keys toggle.\n          const toggles = e.detail === 0 || lastPointer.current === \"touch\";\n          if (isOpen && toggles) group.closeNow();\n          else group.requestOpen(profile.id, measure, true);\n        }}\n        className=\"rounded-[4px] font-medium whitespace-nowrap text-foreground underline decoration-foreground/25 decoration-1 underline-offset-4 outline-hidden transition-[text-decoration-color] duration-150 ease-out hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground aria-expanded:decoration-foreground\"\n      >\n        {profile.handle}\n      </button>\n\n      <AnimatePresence initial={false} custom={exitCustom}>\n        {isOpen && place && (\n          <motion.span\n            key=\"card\"\n            ref={(el: HTMLSpanElement | null) => {\n              cardRef.current = el;\n              if (el) group.setCard(el);\n            }}\n            id={cardId}\n            role=\"group\"\n            aria-label={`${profile.name}, ${profile.handle}`}\n            custom={cardCustom}\n            variants={CARD}\n            initial=\"hidden\"\n            animate=\"shown\"\n            exit=\"exit\"\n            style={{\n              left: place.left,\n              transformOrigin: `${place.originX}px ${place.side === \"bottom\" ? \"0%\" : \"100%\"}`,\n            }}\n            className={cn(\n              \"absolute z-50 block w-[300px] max-w-[calc(100vw-24px)] rounded-[20px] bg-background p-4 text-left text-[14px] leading-5 font-normal whitespace-normal text-foreground shadow-raised\",\n              // An invisible strip across the gap keeps the pointer \"inside\"\n              // while it travels from the link to the card.\n              \"before:absolute before:inset-x-0 before:h-3 before:content-['']\",\n              place.side === \"bottom\"\n                ? \"top-full mt-2 before:-top-3\"\n                : \"bottom-full mb-2 before:-bottom-3\",\n            )}\n          >\n            <span ref={contentRef} className=\"block\">\n              <span className=\"flex items-start justify-between gap-3\">\n                <span className=\"size-12 shrink-0 overflow-hidden rounded-full bg-[oklch(0.97_0_0)] outline-1 -outline-offset-1 outline-[oklch(0_0_0/0.1)] dark:outline-[oklch(1_0_0/0.1)]\">\n                  {/* eslint-disable-next-line @next/next/no-img-element */}\n                  <img src={profile.avatar} alt=\"\" className=\"size-full\" />\n                </span>\n                <FollowButton\n                  following={following}\n                  onToggle={() => setFollowing((f) => !f)}\n                  name={profile.name}\n                />\n              </span>\n              <span className=\"mt-3 block text-[15px] font-semibold tracking-[-0.01em]\">\n                {profile.name}\n              </span>\n              <span className=\"block text-[13px] text-muted-foreground\">\n                {profile.handle}\n              </span>\n              <span className=\"mt-2 block text-pretty\">{profile.bio}</span>\n              <span className=\"mt-3 flex gap-4 text-[13px] text-muted-foreground\">\n                <span>\n                  <span className=\"font-semibold text-foreground tabular-nums\">\n                    {(profile.followers + (following ? 1 : 0)).toLocaleString(\n                      \"en-US\",\n                    )}\n                  </span>{\" \"}\n                  followers\n                </span>\n                <span>\n                  <span className=\"font-semibold text-foreground tabular-nums\">\n                    {profile.following.toLocaleString(\"en-US\")}\n                  </span>{\" \"}\n                  following\n                </span>\n              </span>\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction FollowButton({\n  following,\n  onToggle,\n  name,\n}: {\n  following: boolean;\n  onToggle: () => void;\n  name: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={following}\n      aria-label={`Follow ${name}`}\n      onClick={onToggle}\n      className={cn(\n        \"grid h-9 rounded-full px-4 text-[14px] font-medium outline-hidden transition-[scale,background-color,color,box-shadow] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[background-color,color,box-shadow]\",\n        following\n          ? \"bg-background text-foreground shadow-raised\"\n          : \"bg-foreground text-background\",\n      )}\n    >\n      {/* Both labels share one cell so the button never changes width. */}\n      <span\n        className={cn(\n          \"col-start-1 row-start-1 self-center transition-[opacity] duration-150\",\n          following && \"opacity-0\",\n        )}\n      >\n        Follow\n      </span>\n      <span\n        aria-hidden\n        className={cn(\n          \"col-start-1 row-start-1 self-center transition-[opacity] duration-150\",\n          !following && \"opacity-0\",\n        )}\n      >\n        Following\n      </span>\n    </button>\n  );\n}\n\nconst PEOPLE: Record<string, Profile> = {\n  ava: {\n    id: \"ava\",\n    name: \"Ava Chen\",\n    handle: \"@ava\",\n    avatar: \"/avatars/ava.svg\",\n    bio: \"Design engineer. Writes about springs, gestures and the details nobody notices.\",\n    followers: 12840,\n    following: 312,\n  },\n  ben: {\n    id: \"ben\",\n    name: \"Ben Ortiz\",\n    handle: \"@ben\",\n    avatar: \"/avatars/ben.svg\",\n    bio: \"Builds input systems. Currently teaching trackpads to feel like glass.\",\n    followers: 4096,\n    following: 188,\n  },\n  cara: {\n    id: \"cara\",\n    name: \"Cara Nwosu\",\n    handle: \"@cara\",\n    avatar: \"/avatars/cara.svg\",\n    bio: \"Docs and developer experience. If it needs a paragraph, it needs a better API.\",\n    followers: 2731,\n    following: 540,\n  },\n};\n\nexport default function HoverCardDemo() {\n  return (\n    <HoverCardGroup>\n      <p className=\"w-[440px] max-w-full text-[15px] leading-7 text-pretty text-foreground\">\n        Last week <Mention profile={PEOPLE.ava} /> shipped the new motion\n        guidelines, <Mention profile={PEOPLE.ben} /> rebuilt the gesture system\n        on top of them, and <Mention profile={PEOPLE.cara} /> is already writing\n        the docs. <span className=\"text-muted-foreground\">Hover a name to meet them.</span>\n      </p>\n    </HoverCardGroup>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/hover-card"}