{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-card",
  "type": "registry:ui",
  "title": "Hover card",
  "description": "A preview of what a link points at, opened after the pointer rests on it or at once on keyboard focus, and kept while the pointer crosses into it. Floating glass that grows from the link on the responsive tier. A touch never opens it.",
  "dependencies": [
    "@radix-ui/react-popover",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/hover-card.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/hover-card 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * HoverCard: a preview of what a link points at, shown while the pointer rests on it or keyboard focus is on it\n * (components.md, \"Hover card\"). A person's card behind their name, a project's status behind its link, a commit\n * behind its hash. HoverCard, HoverCardTrigger and HoverCardContent, as shadcn names them.\n *\n * - It only previews. The trigger is a real link or button that leads to the same thing, so a touch screen, which has\n *   no hover, loses nothing: a tap follows the link and the card never opens. Never put the only way to do something\n *   in a hover card.\n * - Opens after the pointer rests 500ms on the trigger (`openDelay`), and at once when keyboard focus lands on it.\n *   Closes 200ms after the pointer leaves both the trigger and the card (`closeDelay`), so the pointer can cross the gap\n *   to a link inside it, and at once on blur or Esc. Focus never moves into it.\n * - Surface: floating glass at the surface radius with the float shadow, 16px of padding, 256 or 320px wide. Solid\n *   (surface-2) when the trigger sits inside another glass surface, as the Popover is: glass never sits on glass.\n * - Motion: the Popover's, on the responsive tier. It grows from the trigger and travels a quarter of the travel token\n *   from the side it opens on, and leaves on the exit token. Under reduced motion it appears and goes.\n * - Built on Radix Popover's positioning (collision flipping, the transform origin), without its dialog role or its\n *   focus moves: the card is supplementary, and what it says must also be reachable by following the trigger.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type FocusEvent,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n} from \"react\";\nimport * as RadixPopover from \"@radix-ui/react-popover\";\nimport { AnimatePresence, animate, motion, useMotionValue } from \"motion/react\";\nimport { useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { useOverlayMotion } from \"../../lib/litt/motion/deal\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\ntype Ctx = {\n  open: boolean;\n  /** Open after the delay (true) or close after the delay (false). */\n  later: (next: boolean) => void;\n  /** Open or close now. */\n  now: (next: boolean) => void;\n  /** Keep it as it is: the pointer came back. */\n  hold: () => void;\n  anchor: RefObject<HTMLElement | null>;\n};\n\nconst HoverCardContext = createContext<Ctx | null>(null);\n\nexport type HoverCardProps = {\n  /** Whether it is open. Pair it with onOpenChange. Leave it out and the card keeps its own state. */\n  open?: boolean;\n  /** Called when it opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** How long the pointer rests on the trigger before it opens, in ms. Keyboard focus opens it at once. */\n  openDelay?: number;\n  /** How long after the pointer leaves the trigger and the card before it closes, in ms. Room to cross the gap. */\n  closeDelay?: number;\n  /** A HoverCardTrigger and a HoverCardContent. */\n  children: ReactNode;\n};\n\n/** The root: holds the open state and the timers. Put a HoverCardTrigger and a HoverCardContent inside it. */\nexport function HoverCard({ open: openProp, onOpenChange, openDelay = 500, closeDelay = 200, children }: HoverCardProps) {\n  const [inner, setInner] = useState(false);\n  const open = openProp ?? inner;\n  const timer = useRef(0);\n  const anchor = useRef<HTMLElement | null>(null);\n\n  const now = useCallback(\n    (next: boolean) => {\n      window.clearTimeout(timer.current);\n      setInner(next);\n      if (next !== open) onOpenChange?.(next);\n    },\n    [open, onOpenChange],\n  );\n  const later = useCallback(\n    (next: boolean) => {\n      window.clearTimeout(timer.current);\n      timer.current = window.setTimeout(() => now(next), next ? openDelay : closeDelay);\n    },\n    [now, openDelay, closeDelay],\n  );\n  const hold = useCallback(() => window.clearTimeout(timer.current), []);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  return (\n    <HoverCardContext value={{ open, later, now, hold, anchor }}>\n      <RadixPopover.Root open={open} onOpenChange={now} modal={false}>\n        {children}\n      </RadixPopover.Root>\n    </HoverCardContext>\n  );\n}\n\nexport type HoverCardTriggerProps = {\n  /** One element: the link or button that leads to what the card previews. It keeps its own look and behavior. */\n  children: ReactElement;\n};\n\n/** Wraps the link the card previews. The pointer resting on it, or keyboard focus, opens the card; a touch never does. */\nexport function HoverCardTrigger({ children }: HoverCardTriggerProps) {\n  const ctx = useContext(HoverCardContext);\n  if (!ctx) throw new Error(\"HoverCardTrigger must sit inside a HoverCard.\");\n  const { later, now, anchor } = ctx;\n  return (\n    <RadixPopover.Anchor\n      {...asChildProps}\n      ref={anchor as Ref<HTMLDivElement>}\n      onPointerEnter={(e: ReactPointerEvent) => {\n        if (e.pointerType !== \"touch\") later(true);\n      }}\n      onPointerLeave={(e: ReactPointerEvent) => {\n        if (e.pointerType !== \"touch\") later(false);\n      }}\n      onFocus={(e: FocusEvent<HTMLElement>) => {\n        /* Keyboard focus only: a tap focuses too, and must not open it. */\n        if (e.currentTarget.matches(\":focus-visible\")) now(true);\n      }}\n      onBlur={() => now(false)}\n      onKeyDown={(e: KeyboardEvent) => {\n        if (e.key === \"Escape\") now(false);\n      }}\n    >\n      {children}\n    </RadixPopover.Anchor>\n  );\n}\n\nconst WIDTH = { sm: \"w-64\", md: \"w-80\" } as const;\n/** What a card must not be glass over: any glass surface, and any popover. */\nconst FLOATING = \".litt-glass, [data-popover-surface]\";\n\n/** Flat: the glass becomes a solid surface-1 card with the float shadow and no hairline. */\nconst FLAT_PANEL =\n  \"in-[[data-material=flat]]:[--litt-glass-bg:var(--litt-surface-1)] in-[[data-material=flat]]:[--litt-glass-border:transparent] \" +\n  \"in-[[data-material=flat]]:[--litt-highlight:0_0_0_0_transparent] in-[[data-material=flat]]:[--litt-glass-blur:0px]\";\n\n\nexport type HoverCardContentProps = {\n  /** The side of the trigger it opens on first. With no room there it flips. */\n  side?: Side;\n  /** Which edge of the trigger it lines up with. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap between the trigger and the card, in px. */\n  sideOffset?: number;\n  /** sm is 256px, md is 320px. It never grows past the room on screen. */\n  size?: \"sm\" | \"md\";\n  /** Draw a solid surface instead of glass. Left out, it is solid only when the trigger sits inside a glass surface. */\n  solid?: boolean;\n  /** The preview: a name and a line, an avatar, a status, a link or two. */\n  children: ReactNode;\n};\n\n/** The card. Render it inside a HoverCard, next to the HoverCardTrigger. */\nexport function HoverCardContent({ side = \"bottom\", align = \"start\", sideOffset = 8, size = \"sm\", solid, children }: HoverCardContentProps) {\n  const ctx = useContext(HoverCardContext);\n  if (!ctx) throw new Error(\"HoverCardContent must sit inside a HoverCard.\");\n  return (\n    <RadixPopover.Portal forceMount>\n      <AnimatePresence>\n        {ctx.open && (\n          <Surface key=\"card\" ctx={ctx} side={side} align={align} sideOffset={sideOffset} size={size} solid={solid}>\n            {children}\n          </Surface>\n        )}\n      </AnimatePresence>\n    </RadixPopover.Portal>\n  );\n}\n\nfunction Surface({\n  ctx,\n  side,\n  align,\n  sideOffset,\n  size,\n  solid,\n  children,\n}: Required<Omit<HoverCardContentProps, \"solid\">> & { ctx: Ctx; solid?: boolean }) {\n  /* The material's motion language (@/motion/deal): it grows from om.grow, and under Device it switches on and off\n     in steps where it stands, with no slide. */\n  const om = useOverlayMotion();\n  const springIn = useTransition(\"responsive\");\n  const springOut = useExit(\"responsive\");\n  const enter = om.switches ? om.switchIn : springIn;\n  const exit = om.switches ? om.switchOut : springOut;\n  const fullTravel = useTravel();\n  const travel = om.slides ? fullTravel : 0;\n  const latest = useRef(enter);\n  latest.current = enter;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const el = useRef<HTMLDivElement | null>(null);\n  /* null until Radix has placed the card; then the side it really opened on. */\n  const [placed, setPlaced] = useState<Side | null>(null);\n  const [overFloating, setOverFloating] = useState(false);\n\n  useLayoutEffect(() => {\n    setOverFloating(Boolean(ctx.anchor.current?.closest(FLOATING)));\n  }, [ctx.anchor]);\n\n  /* A quarter of the travel, starting nearer the trigger than the resting place. */\n  const offset = (s: Side) => {\n    const d = travel / 4;\n    return { x: s === \"left\" ? d : s === \"right\" ? -d : 0, y: s === \"top\" ? d : s === \"bottom\" ? -d : 0 };\n  };\n  const onPlaced = () => {\n    const s = (el.current?.dataset.side as Side | undefined) ?? \"bottom\";\n    const o = offset(s);\n    x.jump(o.x);\n    y.jump(o.y);\n    setPlaced(s);\n  };\n  useEffect(() => {\n    if (!placed) return;\n    animate(x, 0, latest.current);\n    animate(y, 0, latest.current);\n  }, [placed, x, y]);\n  const out = offset(placed ?? side);\n\n  /* Radix forwards onPlaced to its popper, but the popover types omit it. It is a preview, not a dialog: no role, and\n     focus never moves into it or back. */\n  const extra = { onPlaced, role: undefined } as Record<string, unknown>;\n  const flat = solid ?? overFloating;\n\n  return (\n    <RadixPopover.Content\n      forceMount\n      {...asChildProps}\n      side={side}\n      align={align}\n      sideOffset={sideOffset}\n      collisionPadding={8}\n      onOpenAutoFocus={(e) => e.preventDefault()}\n      onCloseAutoFocus={(e) => e.preventDefault()}\n      onFocusOutside={(e) => {\n        /* Focus on the trigger is what opened it: the card mounts during that same focus event, which then reaches\n           Radix's outside-focus listener. It is not a reason to close. */\n        if (ctx.anchor.current?.contains(e.target as Node)) e.preventDefault();\n      }}\n      {...extra}\n    >\n      <motion.div\n        ref={el}\n        data-popover-surface=\"\"\n        onPointerEnter={(e) => {\n          if (e.pointerType !== \"touch\") ctx.hold();\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType !== \"touch\") ctx.later(false);\n        }}\n        className={\n          \"relative z-50 max-w-(--radix-popover-content-available-width) rounded-litt-surface outline-none \" +\n          \"origin-(--radix-popover-content-transform-origin) data-[state=closed]:pointer-events-none \" +\n          WIDTH[size] +\n          (flat ? \" bg-litt-surface-2 shadow-litt-float in-[[data-material=flat]]:bg-litt-surface-1\" : \" litt-glass \" + FLAT_PANEL)\n        }\n        initial={{ opacity: 0, scale: om.grow }}\n        animate={{ opacity: placed ? 1 : 0, scale: placed ? 1 : om.grow, transition: enter }}\n        exit={{ opacity: 0, scale: om.grow, x: out.x, y: out.y, transition: exit }}\n        style={{ x, y }}\n      >\n        {/* Scrolls inside the card when the screen is shorter than it. The padding sits inside the scroller, so a focus\n            ring is never clipped, and the card's own glass keeps its shadow. */}\n        <div className=\"max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-litt-surface p-4\">{children}</div>\n      </motion.div>\n    </RadixPopover.Content>\n  );\n}\n",
      "target": "components/litt/hover-card.tsx"
    }
  ],
  "docs": "Import HoverCard, HoverCardTrigger and HoverCardContent from components/litt/hover-card, as shadcn's hover card is written. The trigger must be a real link to the same information: never put anything only in the card.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
