{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hover-card","type":"registry:ui","title":"HoverCard","description":"An anchored panel that opens on hover or focus with a short delay, for richer previews than a tooltip — a profile card, a link preview","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["overlays"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/hover-card/HoverCard.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useRef, useState } from \"react\";\nimport type { FocusEvent, ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport type { Side, Align } from \"../../internals/anchor-position\";\nimport { Portal } from \"../../internals/Portal\";\nimport { useAnchorPosition } from \"../../internals/use-anchor-position\";\nimport { useDismissable } from \"../../internals/dismissable\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useEventCallback } from \"../../internals/dom/use-event-callback\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { anchored, originFor } from \"../../internals/motion/anchored\";\nimport { usePresence } from \"../../internals/motion/presence\";\n\nexport interface HoverCardProps {\n\t/**\n\t * Whether the card is open.\n\t *\n\t * The source declares this bindable; React has no two-way channel, so the\n\t * component keeps its own copy either way and re-syncs it whenever the\n\t * CALLER changes the prop. That is what makes all three documented call\n\t * shapes work off one implementation — a caller driving `open` from its\n\t * own state, a caller who passes only `onOpenChange`, and a caller who\n\t * passes a plain unbound value alongside it.\n\t */\n\topen?: boolean;\n\t/** Fires whenever the open state changes, from any trigger — pointer, focus, Escape or an outside click. */\n\tonOpenChange?: (open: boolean) => void;\n\t/** Side of the trigger the card opens on. Flips to the opposite side when it would overflow the viewport. */\n\tside?: Side;\n\t/** Alignment along the trigger's cross axis. */\n\talign?: Align;\n\t/** Gap in pixels between the trigger and the card. */\n\toffset?: number;\n\t/** Delay in ms before the card opens after the pointer enters the trigger. Ignored for focus, which opens immediately. */\n\topenDelay?: number;\n\t/**\n\t * Delay in ms before the card closes after the pointer leaves the trigger\n\t * or the card. Gives the pointer time to travel from one to the other\n\t * without the card vanishing mid-trip. Ignored for blur, which closes\n\t * immediately.\n\t */\n\tcloseDelay?: number;\n\t/**\n\t * The element that opens the card on hover or focus. Called with the\n\t * card's id (or `undefined` while closed) — put it on your own trigger\n\t * element's `aria-describedby` yourself. HoverCard cannot do this for\n\t * you: the wrapper it renders around this node has no accessible role of\n\t * its own, so an attribute set there would not be picked up for whatever\n\t * focusable element you render inside it.\n\t */\n\ttrigger?: (descriptionId: string | undefined) => ReactNode;\n\t/**\n\t * The card's content. Supplementary only — nothing inside should be the\n\t * only way to reach information or an action. See the README before\n\t * putting links or buttons in here.\n\t */\n\tchildren?: ReactNode;\n\t/** Additional classes for the card panel. */\n\tclassName?: string;\n}\n\n/**\n * A rich preview card that opens on hover or focus, anchored to its trigger.\n *\n * The source declares `ref = $bindable(null)` for the TRIGGER WRAPPER\n * element, so that element arrives through this component's ref channel\n * (PORTING.md §\"API contract\") rather than through a prop.\n */\nexport const HoverCard = forwardRef<HTMLDivElement, HoverCardProps>(function HoverCard(\n\t{\n\t\topen: openProp,\n\t\tonOpenChange,\n\t\tside = \"bottom\",\n\t\talign = \"center\",\n\t\toffset = 8,\n\t\topenDelay = 300,\n\t\tcloseDelay = 150,\n\t\ttrigger,\n\t\tchildren,\n\t\tclassName,\n\t},\n\tforwardedRef\n) {\n\t// One id, stable across SSR and hydration — `uid()` would throw on the\n\t// server, and this needs to exist before the card ever opens so the\n\t// trigger's aria-describedby always resolves to a real element the moment\n\t// it points at one. `useFancyId()` is the counterpart of the source's\n\t// `$props.id()` (convention C-6).\n\tconst panelId = useFancyId();\n\n\t// The React shape of the source's `open = $bindable(false)`: an internal\n\t// copy seeded from the prop, re-synced during render whenever the CALLER\n\t// changes the prop, and free to move on its own in between. Re-synced in\n\t// the render path, not an effect — an effect would paint one frame of the\n\t// stale value first.\n\tconst [open, setOpenState] = useState(openProp ?? false);\n\tconst [lastOpenProp, setLastOpenProp] = useState(openProp);\n\tif (lastOpenProp !== openProp) {\n\t\tsetLastOpenProp(openProp);\n\t\tsetOpenState(openProp ?? false);\n\t}\n\n\tconst openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\tconst closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n\tfunction clearOpenTimer() {\n\t\tif (openTimer.current !== undefined) {\n\t\t\tclearTimeout(openTimer.current);\n\t\t\topenTimer.current = undefined;\n\t\t}\n\t}\n\n\tfunction clearCloseTimer() {\n\t\tif (closeTimer.current !== undefined) {\n\t\t\tclearTimeout(closeTimer.current);\n\t\t\tcloseTimer.current = undefined;\n\t\t}\n\t}\n\n\t// The one place `open` changes. The guard is what makes a change that\n\t// changes nothing fire nothing: a second Escape during the fade must not\n\t// call `onOpenChange(false)` a second time. Read through a live ref so\n\t// the timer callbacks scheduled below always compare against the current\n\t// value, not the render they were scheduled in.\n\tconst openRef = useLiveRef(open);\n\t// The notification goes out through the house event-callback wrapper for\n\t// the same reason: a timer scheduled at pointerenter fires up to\n\t// `openDelay` later, and it must call the callback the caller has THEN,\n\t// not the closure captured when the delay started. The source reads\n\t// `onOpenChange` off `$props()` at call time, so a parent that re-rendered\n\t// inside the window is notified against its current state. It also keeps\n\t// `setOpen` identity-stable.\n\tconst emitOpenChange = useEventCallback(onOpenChange);\n\tconst setOpen = useCallback(\n\t\t(next: boolean) => {\n\t\t\tclearOpenTimer();\n\t\t\tclearCloseTimer();\n\t\t\tif (openRef.current === next) return;\n\t\t\tsetOpenState(next);\n\t\t\temitOpenChange(next);\n\t\t},\n\t\t[openRef, emitOpenChange]\n\t);\n\n\tfunction scheduleOpen() {\n\t\t// A pointer arriving back on the trigger while a close is pending (it\n\t\t// travelled trigger → card → trigger) must cancel that close rather\n\t\t// than restart the open delay — the card never actually closed.\n\t\tclearCloseTimer();\n\t\tif (open) return;\n\t\tclearOpenTimer();\n\t\topenTimer.current = setTimeout(() => setOpen(true), openDelay);\n\t}\n\n\t// Called when the pointer leaves the trigger AND when it leaves the card.\n\t// The delay is what lets it cross the gap between the two: if it lands on\n\t// the other one before this fires, that element's own pointerenter clears\n\t// the timer first.\n\tfunction scheduleClose() {\n\t\tclearOpenTimer();\n\t\tif (!open) return;\n\t\tclearCloseTimer();\n\t\tcloseTimer.current = setTimeout(() => setOpen(false), closeDelay);\n\t}\n\n\t// The source's `onMount` teardown: both timers die with the component.\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tif (openTimer.current !== undefined) clearTimeout(openTimer.current);\n\t\t\tif (closeTimer.current !== undefined) clearTimeout(closeTimer.current);\n\t\t};\n\t}, []);\n\n\t// A plain ref, not state: nothing renders off it. The panel reads it\n\t// lazily, through an anchor getter and an exclude getter that both run\n\t// after the wrapper has committed — the wrapper is this component's own\n\t// first child, so its ref is attached before the panel's subtree runs any\n\t// effect of its own.\n\tconst triggerRef = useRef<HTMLDivElement | null>(null);\n\tconst triggerWrapperRef = useComposedRefs(triggerRef, forwardedRef);\n\n\t// The NODE, not a ref (convention C-1): the panel is created by\n\t// `presence.mounted`, so a `useRef` read inside a `[]`-deps effect would\n\t// still be null when the position and dismiss effects fire and neither\n\t// would ever arm — silently.\n\tconst [panel, setPanelNode] = useElementRef<HTMLDivElement>();\n\n\t// The mount clock. It keeps the panel on screen for the length of its\n\t// exit — the job the source's `{#if open}` branch plus outro did — and\n\t// sets `inert` on the registered node for that whole window, which is\n\t// what stops the pointer interacting with a card it has already left.\n\tconst presence = usePresence(open);\n\n\t// The placement as ACTUALLY resolved — the requested side and align until\n\t// `computePosition` flips or clamps it away from a viewport edge. Seeded\n\t// with the REQUESTED values by the hook itself rather than a hardcoded\n\t// \"bottom\"/\"center\", so a card that never flips reads the right growth\n\t// origin without depending on whether the first placement has run yet.\n\t//\n\t// `resolvedAlign` differs from the requested alignment whenever clamping\n\t// slid the panel along the cross axis — near a viewport edge the\n\t// requested corner is no longer the one touching the anchor, and an\n\t// entrance grown from it would expand from the far corner instead.\n\tconst { side: resolvedSide, align: resolvedAlign } = useAnchorPosition(panel, {\n\t\tanchor: () => triggerRef.current,\n\t\tside,\n\t\talign,\n\t\toffset,\n\t});\n\n\t// `active: open` — a plain boolean where the source needed a getter. The\n\t// layer stays ON the stack for its whole exit and stops being TOP of it\n\t// the instant `open` flips, so an Escape during the fade reaches whatever\n\t// layer is underneath rather than being swallowed by a card that is\n\t// leaving.\n\tuseDismissable(panel, {\n\t\tonDismiss: () => setOpen(false),\n\t\texclude: () => [triggerRef.current],\n\t\tactive: open,\n\t});\n\n\t// Convention C-2: composed ABOVE the conditional below.\n\t//\n\t// ONE bidirectional transition, never a split in/out pair. This is the\n\t// directive that earns its keep most on a hover surface: pointers change\n\t// their mind, and a unified leg passes the in-flight counterpart's\n\t// current position into the fresh call, so a card the pointer comes back\n\t// to mid-fade continues from where it is instead of snapping to invisible\n\t// and starting the entrance over. `entering` is what tells it which way\n\t// it is going, and the params are read at the instant each leg starts.\n\tconst panelRef = useComposedRefs(\n\t\tsetPanelNode,\n\t\tpresence.register(anchored, (entering) => ({ side: resolvedSide, entering }))\n\t);\n\n\t// The documented contract is that nothing inside the card is interactive\n\t// (see the README), so in the shape this component was designed for,\n\t// focus never moves from the trigger into the card and this check never\n\t// matters. It exists for the caller who ignores that contract anyway: an\n\t// unconditional close-on-blur would unmount the card the instant Tab\n\t// starts moving focus toward whatever they put inside it — vanishing out\n\t// from under a keyboard user reaching for something a mouse user could\n\t// already click freely, since a mouse click never routes through the\n\t// trigger's focus at all. Checking `relatedTarget` against the panel is\n\t// the standard trigger-to-content handoff: don't close if focus is\n\t// headed into the very thing that would otherwise disappear.\n\tfunction handleTriggerFocusOut(event: FocusEvent<HTMLDivElement>) {\n\t\tconst next = event.relatedTarget as Node | null;\n\t\tif (next && panel?.contains(next)) return;\n\t\tsetOpen(false);\n\t}\n\n\tconst classes = cn(\n\t\t\"ft-hover-card-panel bg-popover text-popover-foreground border-border flex w-60 flex-col gap-2.5 rounded-xl border p-3.5 shadow-[0_12px_32px_rgba(0,0,0,.5)]\",\n\t\tclassName\n\t);\n\n\t// `closeDelay` and the exit are two different waits and both are wanted.\n\t// `closeDelay` is the grace period the pointer gets to travel from the\n\t// trigger to the card, spent BEFORE anything visible happens; the exit is\n\t// the card leaving, spent after. `open` still flips at the end of the\n\t// delay, so `onOpenChange(false)` and the caller's controlled value are\n\t// exactly where they were — only the removal now trails it by 150 ms.\n\t//\n\t// The `Portal` stays ABOVE the mounted gate, and the gate wraps its\n\t// CHILDREN, so the portal resolves a container BEFORE the commit that\n\t// opens the card and the entrance leg has a node to attach to.\n\t//\n\t// `data-state` is an ordinary attribute (divergence D-2) carrying\n\t// `surfaceState`'s TWO values — never `\"opening\"` (convention C-5). The\n\t// source needed `markSurfaceState` only because its scheduler skips\n\t// effects inside a closing branch; React re-renders the exiting surface\n\t// normally. `inert` is not written by hand either: `usePresence` sets it\n\t// on the registered node for the whole exit.\n\t//\n\t// `data-align` publishes the REQUESTED alignment, exactly as the source\n\t// does; only the transform origin follows the resolved one.\n\t//\n\t// Reduced motion needs no rule of its own: `anchored` collapses the\n\t// duration to 0, `runTransition`'s falsy-duration fast path then skips\n\t// `element.animate()` entirely, and the card appears and disappears in\n\t// the frame it mounts and unmounts. Its visibility never depended on the\n\t// animation — the presence clock alone decides that.\n\treturn (\n\t\t<>\n\t\t\t<div\n\t\t\t\tref={triggerWrapperRef}\n\t\t\t\tclassName=\"ft-hover-card-trigger inline-block\"\n\t\t\t\tonPointerEnter={scheduleOpen}\n\t\t\t\tonPointerLeave={scheduleClose}\n\t\t\t\tonFocus={() => setOpen(true)}\n\t\t\t\tonBlur={handleTriggerFocusOut}\n\t\t\t>\n\t\t\t\t{trigger?.(open ? panelId : undefined)}\n\t\t\t</div>\n\t\t\t<Portal>\n\t\t\t\t{presence.mounted ? (\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={panelRef}\n\t\t\t\t\t\tid={panelId}\n\t\t\t\t\t\tclassName={classes}\n\t\t\t\t\t\tdata-state={presence.surfaceState}\n\t\t\t\t\t\tdata-side={resolvedSide}\n\t\t\t\t\t\tdata-align={align}\n\t\t\t\t\t\tstyle={{ transformOrigin: originFor(resolvedSide, resolvedAlign) }}\n\t\t\t\t\t\tonPointerEnter={clearCloseTimer}\n\t\t\t\t\t\tonPointerLeave={scheduleClose}\n\t\t\t\t\t>\n\t\t\t\t\t\t{children}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</Portal>\n\t\t</>\n\t);\n});\n\nHoverCard.displayName = \"HoverCard\";\n","type":"registry:ui","target":"@ui/fancy/components/hover-card/HoverCard.tsx"},{"path":"react/src/components/hover-card/index.ts","content":"\"use client\";\n\nexport { HoverCard } from \"./HoverCard\";\nexport type { HoverCardProps } from \"./HoverCard\";\n","type":"registry:ui","target":"@ui/fancy/components/hover-card/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/hover-card?framework=react"}}