{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-card",
  "type": "registry:ui",
  "title": "Hover Card",
  "description": "An accessible React hover card for Next.js and Tailwind CSS: a preview card for a link, like a profile on an @mention, built on Radix Hover Card.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/hover-card.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { blur, duration, ease } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { type AnimationPlaybackControls, animate, useReducedMotion } from \"motion/react\";\nimport { HoverCard as HoverCardPrimitive } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  createContext,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\n/*\n * Hover Card, built on Radix Hover Card: a preview of what a link points to (a profile, a page) for sighted\n * users. It opens when the pointer rests on the link or the link takes keyboard focus, stays open while the\n * pointer moves onto it, and closes on Escape. Screen readers don't get the card, so the link has to make\n * sense on its own.\n * - The surface and motion are Popover's: it opens out of the link through the blur, from Radix's\n *   transform origin, and closes back into it.\n * - It waits 300ms before opening, as Tooltip does (Radix waits 700ms, which made the first card feel slow).\n * - From one link to the next (names in a comment, people in a list): once a card has been open, the next\n *   opens at once and takes its place, with no delay. The two read as one card that moves: the new one\n *   starts where the last one was, at its size, and glides to its own place and size (Navigation Menu's\n *   panel: transform for the place, width and height for the size, duration.base + ease.out), while its\n *   content slides 12px in from the side it moved towards, through the blur. The last one goes in the same\n *   frame. With reduced motion it simply appears in its place.\n * - Nothing animates on first paint.\n */\n\n/** How far the content slides in as the card moves to the next link, in px (Navigation Menu's) */\nconst SLIDE = 12;\n/** The inline styles the glide leaves on a card and its content, cleared when it's done */\nconst LEFTOVERS = [\"width\", \"height\", \"overflow\", \"transform\"];\nconst CONTENT_LEFTOVERS = [\"opacity\", \"transform\", \"filter\"];\n\ntype Shown = { hide: () => void; measure: () => DOMRect | null };\n\n/** Page-wide: the card on show (open, or the last one that closed), whether it's open, and when it closed */\nconst shown: { card: Shown | null; open: boolean; closedAt: number } = {\n  card: null,\n  open: false,\n  closedAt: Number.NEGATIVE_INFINITY,\n};\n\n/** One card's way of coming and going, read by its trigger and content */\nconst CardContext = createContext<{\n  following: boolean;\n  hidden: boolean;\n  openAtOnce: () => void;\n  /** The card's surface on the page, so the next card can start from where it is */\n  surface: RefObject<HTMLDivElement | null>;\n  /** Where the card this one replaced was, taken by the content as it mounts */\n  from: RefObject<DOMRect | null>;\n} | null>(null);\n\nexport type HoverCardProps = ComponentProps<typeof HoverCardPrimitive.Root> & {\n  /** Milliseconds after a card closes during which the next one opens at once, without the delay */\n  skipDelayDuration?: number;\n};\n\nfunction HoverCard({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  openDelay = 300,\n  closeDelay = 300,\n  skipDelayDuration = 300,\n  ...props\n}: HoverCardProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  // Open from the start counts as following: it's simply there, with nothing animating on first paint\n  const [following, setFollowing] = useState(defaultOpen);\n  const [hidden, setHidden] = useState(false);\n  const open = openProp ?? uncontrolled;\n  // This card in the page-wide record. Its methods are refreshed after every render, so they see the latest state.\n  const card = useRef<Shown>({ hide: () => {}, measure: () => null });\n  const surface = useRef<HTMLDivElement | null>(null);\n  const from = useRef<DOMRect | null>(null);\n\n  const setOpen = (next: boolean, follows = false) => {\n    // Radix's timers can report a state it's already in, for example after the card opened at once\n    if (next === open) return;\n    if (next) {\n      // One card at a time: the one on show gives way in the same frame, and this one starts from where it was\n      from.current = null;\n      if (shown.card && shown.card !== card.current) {\n        if (follows && shown.open) from.current = shown.card.measure();\n        shown.card.hide();\n      }\n      shown.card = card.current;\n      shown.open = true;\n      setFollowing(follows);\n      setHidden(false);\n    } else if (shown.card === card.current) {\n      shown.open = false;\n      shown.closedAt = performance.now();\n    }\n    if (openProp === undefined) setUncontrolled(next);\n    onOpenChange?.(next);\n  };\n\n  useLayoutEffect(() => {\n    card.current.hide = () => {\n      setHidden(true);\n      setOpen(false);\n    };\n    card.current.measure = () =>\n      surface.current?.isConnected ? surface.current.getBoundingClientRect() : null;\n  });\n\n  useEffect(() => {\n    const self = card.current;\n    return () => {\n      if (shown.card !== self) return;\n      shown.card = null;\n      shown.open = false;\n    };\n  }, []);\n\n  /** Called as the pointer or focus reaches the link: skip the delay if another card was just on show */\n  const openAtOnce = () => {\n    if (open) return;\n    if (shown.open || performance.now() - shown.closedAt < skipDelayDuration) setOpen(true, true);\n  };\n\n  return (\n    <CardContext.Provider value={{ following, hidden, openAtOnce, surface, from }}>\n      <HoverCardPrimitive.Root\n        data-slot=\"hover-card\"\n        open={open}\n        onOpenChange={(next) => setOpen(next)}\n        openDelay={openDelay}\n        closeDelay={closeDelay}\n        {...props}\n      />\n    </CardContext.Provider>\n  );\n}\n\nfunction HoverCardTrigger({\n  onPointerEnter,\n  onFocus,\n  ...props\n}: ComponentProps<typeof HoverCardPrimitive.Trigger>) {\n  const card = useContext(CardContext);\n\n  return (\n    <HoverCardPrimitive.Trigger\n      data-slot=\"hover-card-trigger\"\n      // These run before Radix's own, which still start its delay; opening at once makes that a no-op\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        if (!event.defaultPrevented && event.pointerType !== \"touch\") card?.openAtOnce();\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        if (!event.defaultPrevented) card?.openAtOnce();\n      }}\n      {...props}\n    />\n  );\n}\n\ntype Edge = \"start\" | \"end\" | \"center\";\n\n/** A point along one axis of a box: its start, its end or its centre */\nconst along = (start: number, size: number, edge: Edge) =>\n  edge === \"start\" ? start : edge === \"end\" ? start + size : start + size / 2;\n\n/** The edge Radix holds still on an axis while the card's size changes: the one by the link, or the aligned one */\nfunction edgeOf(side: string | undefined, align: string | undefined, axis: \"x\" | \"y\"): Edge {\n  const [before, after] = axis === \"x\" ? [\"left\", \"right\"] : [\"top\", \"bottom\"];\n  if (side === before) return \"end\";\n  if (side === after) return \"start\";\n  return align === \"start\" ? \"start\" : align === \"end\" ? \"end\" : \"center\";\n}\n\n/**\n * Glides a card that took another's place from where that one was: its place (transform) and its size (width\n * and height), while its content comes in through the blur from the side it moved towards. Returns a stop.\n */\nfunction glideFrom(node: HTMLDivElement, from: DOMRect) {\n  const to = node.getBoundingClientRect();\n  if (to.width === 0 || to.height === 0) return () => {};\n  const { side, align } = node.dataset;\n  const xEdge = edgeOf(side, align, \"x\");\n  const yEdge = edgeOf(side, align, \"y\");\n  const dx = along(from.left, from.width, xEdge) - along(to.left, to.width, xEdge);\n  const dy = along(from.top, from.height, yEdge) - along(to.top, to.height, yEdge);\n  const timing = { duration: duration.base, ease: ease.out };\n  const content = node.firstElementChild instanceof HTMLElement ? node.firstElementChild : null;\n\n  // Clipped only while the size moves\n  node.style.overflow = \"hidden\";\n  const runs: AnimationPlaybackControls[] = [\n    animate(\n      node,\n      { x: [dx, 0], y: [dy, 0], width: [from.width, to.width], height: [from.height, to.height] },\n      timing,\n    ),\n  ];\n  if (content) {\n    const horizontal = Math.abs(dx) >= Math.abs(dy);\n    // Moving right, the content comes in from the right (as Navigation Menu's)\n    const towards = Math.sign(horizontal ? -dx : -dy);\n    const slide = horizontal ? { x: [towards * SLIDE, 0] } : { y: [towards * SLIDE, 0] };\n    runs.push(animate(content, { opacity: [0, 1], filter: [blur.hidden, blur.shown], ...slide }, timing));\n  }\n\n  // A filter or size left behind would break the next glide and its closing keyframes\n  const clear = () => {\n    for (const property of LEFTOVERS) node.style.removeProperty(property);\n    if (content) for (const property of CONTENT_LEFTOVERS) content.style.removeProperty(property);\n  };\n  Promise.all(runs).then(clear);\n  return () => {\n    for (const run of runs) run.stop();\n    clear();\n  };\n}\n\nfunction HoverCardContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  children,\n  ref,\n  ...props\n}: ComponentProps<typeof HoverCardPrimitive.Content>) {\n  const card = useContext(CardContext);\n  const reduce = useReducedMotion() ?? false;\n  const surface = card?.surface;\n  const start = card?.from;\n\n  // Stable, so a re-render while it glides doesn't detach and re-attach it (which would stop the glide)\n  const attach = useCallback(\n    (node: HTMLDivElement | null) => {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n      if (!surface || !start || !node) return;\n      surface.current = node;\n      // Read, not taken: React attaches a ref twice in development (Strict Mode), and taking it on the\n      // first attach left the real one with nothing to glide from. Each opening sets it afresh.\n      const from = start.current;\n      let stop: (() => void) | undefined;\n      let observer: MutationObserver | undefined;\n      if (from && from.width > 0 && !reduce) {\n        // Radix places the card just after it mounts; until then its wrapper is parked off-screen. That\n        // change lands before the browser paints, so the glide starts on the first frame shown.\n        const wrapper = node.parentElement;\n        const placed = () => !wrapper?.style.transform.includes(\"-200%\");\n        if (placed()) stop = glideFrom(node, from);\n        else if (wrapper) {\n          observer = new MutationObserver(() => {\n            if (!placed()) return;\n            observer?.disconnect();\n            stop = glideFrom(node, from);\n          });\n          observer.observe(wrapper, { attributes: true, attributeFilter: [\"style\"] });\n        }\n      }\n      return () => {\n        observer?.disconnect();\n        stop?.();\n        if (surface.current === node) surface.current = null;\n      };\n    },\n    [ref, surface, start, reduce],\n  );\n\n  return (\n    <HoverCardPrimitive.Portal>\n      <HoverCardPrimitive.Content\n        ref={attach}\n        data-slot=\"hover-card-content\"\n        data-following={card?.following ? \"\" : undefined}\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          \"z-(--z-popover) w-64 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md outline-hidden\",\n          \"origin-(--radix-hover-card-content-transform-origin) data-[state=closed]:animate-popover-out\",\n          // A card that takes another's place glides from it instead\n          !card?.following && \"data-[state=open]:animate-popover-in\",\n          // Handed over to the next card: gone at once, while Radix waits out the closing keyframes\n          card?.hidden && \"invisible\",\n          className,\n        )}\n        {...props}\n      >\n        {/* One element round the content, so it can come in through the blur while the card glides */}\n        <div data-slot=\"hover-card-body\">{children}</div>\n      </HoverCardPrimitive.Content>\n    </HoverCardPrimitive.Portal>\n  );\n}\n\nexport { HoverCard, HoverCardContent, HoverCardTrigger };\n"
    }
  ],
  "categories": [
    "overlays"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/hover-card"
}
