{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-card",
  "type": "registry:ui",
  "title": "Hover Card",
  "description": "Profile and link previews that prefetch on intent and draw the wait.",
  "categories": [
    "dialogs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/hover-card.tsx",
      "content": "\"use client\";\nimport { PreviewCard } from \"@base-ui/react/preview-card\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, use, useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { Clock, Globe, Refresh } from \"@/lib/icons\";\n\ntype Status = \"idle\" | \"loading\" | \"ready\" | \"error\";\n\nexport type HoverCardLoadState<T> = { status: Status; data: T | undefined; retry: () => void };\n\ntype Ctx = { state: HoverCardLoadState<unknown>; prefetch: () => void };\nconst HoverCardContext = createContext<Ctx | null>(null);\nconst DelayContext = createContext({ delay: 500, closeDelay: 200 });\n\nconst useCtx = () => {\n  const ctx = use(HoverCardContext);\n  if (!ctx) throw new Error(\"HoverCard parts must be inside <HoverCard>\");\n  return ctx;\n};\n\n/** The load state of the nearest HoverCard, for building your own content. */\nexport function useHoverCard<T>() {\n  return useCtx().state as HoverCardLoadState<T>;\n}\n\nexport type HoverCardProps<T = unknown> = Omit<PreviewCard.Root.Props, \"children\"> & {\n  children?: React.ReactNode;\n  /**\n   * Fetches what the card shows. Starts the moment the pointer or focus arrives on the\n   * trigger, so the open delay hides most of the latency. Called once; a failed load\n   * retries on the next open.\n   */\n  load?: () => Promise<T>;\n  /** Milliseconds of hover or focus before the card opens. */\n  delay?: number;\n  /** Milliseconds before it closes once the pointer has left both trigger and card. */\n  closeDelay?: number;\n};\n\nexport function HoverCard<T = unknown>({ load, delay = 500, closeDelay = 200, onOpenChange, children, ...rest }: HoverCardProps<T>) {\n  const [state, setState] = useState<{ status: Status; data: T | undefined }>({ status: load ? \"idle\" : \"ready\", data: undefined });\n  const status = useRef<Status>(load ? \"idle\" : \"ready\");\n  const loadRef = useRef(load);\n  const request = useRef(0);\n  useEffect(() => {\n    loadRef.current = load;\n  });\n  // Stale responses never land: each request has an id, and unmount bumps it.\n  useEffect(() => () => void request.current++, []);\n\n  const start = useCallback(() => {\n    const fn = loadRef.current;\n    if (!fn || status.current === \"loading\" || status.current === \"ready\") return;\n    status.current = \"loading\";\n    setState((s) => ({ ...s, status: \"loading\" }));\n    const id = ++request.current;\n    Promise.resolve()\n      .then(fn)\n      .then(\n        (data) => {\n          if (id !== request.current) return;\n          status.current = \"ready\";\n          setState({ status: \"ready\", data });\n        },\n        () => {\n          if (id !== request.current) return;\n          status.current = \"error\";\n          setState((s) => ({ ...s, status: \"error\" }));\n        },\n      );\n  }, []);\n\n  const ctx: Ctx = { state: { ...state, retry: start }, prefetch: start };\n\n  return (\n    <HoverCardContext value={ctx}>\n      <PreviewCard.Root\n        onOpenChange={(open, details) => {\n          if (open) start();\n          onOpenChange?.(open, details);\n        }}\n        {...rest}\n      >\n        <DelayContext value={{ delay, closeDelay }}>{children}</DelayContext>\n      </PreviewCard.Root>\n    </HoverCardContext>\n  );\n}\n\nexport type HoverCardTriggerProps = Omit<PreviewCard.Trigger.Props, \"className\" | \"delay\" | \"closeDelay\"> & {\n  className?: string;\n  /** \"link\" underlines, \"mention\" is a soft chip, \"bare\" leaves styling to you. */\n  variant?: \"link\" | \"mention\" | \"bare\";\n};\n\nexport function HoverCardTrigger({ variant = \"link\", className, style, onPointerEnter, onFocus, ...rest }: HoverCardTriggerProps) {\n  const { prefetch } = useCtx();\n  const { delay, closeDelay } = use(DelayContext);\n  return (\n    <PreviewCard.Trigger\n      delay={delay}\n      closeDelay={closeDelay}\n      data-variant={variant}\n      onPointerEnter={(e) => {\n        prefetch();\n        onPointerEnter?.(e);\n      }}\n      onFocus={(e) => {\n        prefetch();\n        onFocus?.(e);\n      }}\n      style={{ \"--hc-delay\": `${delay}ms`, ...(typeof style === \"object\" ? style : null) } as React.CSSProperties}\n      className={cn(\n        \"rounded-[3px] outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        // Two background lines: a faint resting underline, and a brighter one that draws\n        // across over exactly the open delay, so the wait reads as \"coming\" rather than \"broken\".\n        // Backgrounds follow wrapped lines, which a pseudo-element can't.\n        variant !== \"bare\" && \"cursor-pointer bg-no-repeat [background-position:0_100%,0_100%]\",\n        variant !== \"bare\" &&\n          \"transition-[background-size,color] duration-150 ease-out-quart hover:duration-(--hc-delay) hover:ease-linear data-popup-open:duration-150 motion-reduce:duration-0\",\n        variant === \"link\" &&\n          \"pb-px text-fg [background-image:linear-gradient(var(--fg-2),var(--fg-2)),linear-gradient(var(--fg-4),var(--fg-4))] [background-size:0%_1px,100%_1px] hover:[background-size:100%_1px,100%_1px] focus-visible:[background-size:100%_1px,100%_1px] data-popup-open:[background-size:100%_1px,100%_1px]\",\n        variant === \"mention\" &&\n          \"px-[3px] py-px font-medium text-fg [box-decoration-break:clone] [background-image:linear-gradient(color-mix(in_oklab,var(--fg)_8%,transparent),color-mix(in_oklab,var(--fg)_8%,transparent)),linear-gradient(color-mix(in_oklab,var(--fg)_6%,transparent),color-mix(in_oklab,var(--fg)_6%,transparent))] [background-size:0%_100%,100%_100%] hover:[background-size:100%_100%,100%_100%] data-popup-open:[background-size:100%_100%,100%_100%]\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nconst widths = { sm: \"w-64\", md: \"w-72\", lg: \"w-80\" } as const;\n\nexport type HoverCardContentProps<T = unknown> = Omit<PreviewCard.Popup.Props, \"className\" | \"children\"> & {\n  className?: string;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  sideOffset?: number;\n  size?: keyof typeof widths;\n  container?: PreviewCard.Portal.Props[\"container\"];\n  /** Content, or a function of the load state for custom layouts. */\n  children?: React.ReactNode | ((state: HoverCardLoadState<T>) => React.ReactNode);\n};\n\nexport function HoverCardContent<T = unknown>({\n  side = \"bottom\",\n  align = \"start\",\n  sideOffset = 8,\n  size = \"md\",\n  container,\n  className,\n  children,\n  ...rest\n}: HoverCardContentProps<T>) {\n  const { state } = useCtx();\n  return (\n    <PreviewCard.Portal container={container}>\n      <PreviewCard.Positioner side={side} align={align} sideOffset={sideOffset} collisionPadding={8} className=\"z-(--z-popover)\">\n        <PreviewCard.Popup\n          className={cn(\n            \"max-w-[var(--available-width)] rounded-xl border border-line-2 bg-raised p-3.5 text-[13px] text-fg shadow-pop outline-none\",\n            widths[size],\n            \"origin-[var(--transform-origin)] transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n            \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n            \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n            \"data-[side=left]:data-starting-style:translate-x-1 data-[side=right]:data-starting-style:-translate-x-1\",\n            \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n            \"data-instant:transition-none\",\n            \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n            className,\n          )}\n          {...rest}\n        >\n          {typeof children === \"function\" ? children(state as HoverCardLoadState<T>) : children}\n        </PreviewCard.Popup>\n      </PreviewCard.Positioner>\n    </PreviewCard.Portal>\n  );\n}\n\n// Skeleton and content share one grid cell and are built to the same geometry, so the\n// swap is a 200ms crossfade with nothing moving. When the data was already there at\n// open (the usual case, thanks to prefetch), there is no fade at all.\nfunction Swap({ loaded, skeleton, children }: { loaded: boolean; skeleton: React.ReactNode; children: React.ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <div className=\"grid\" aria-busy={!loaded || undefined}>\n      <AnimatePresence initial={false}>\n        <motion.div\n          key={loaded ? \"content\" : \"skeleton\"}\n          className=\"col-start-1 row-start-1 min-w-0\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n        >\n          {loaded ? children : skeleton}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n\nconst Bone = ({ className }: { className?: string }) => <span aria-hidden className={cn(\"block animate-pulse-soft rounded-[4px] bg-fg/[0.07] motion-reduce:animate-none\", className)} />;\n\nfunction Failed({ what, onRetry }: { what: string; onRetry: () => void }) {\n  return (\n    <div role=\"alert\" className=\"flex items-center justify-between gap-3 py-0.5\">\n      <p className=\"text-[12.5px] text-fg-2\">Couldn’t load {what}</p>\n      <button\n        type=\"button\"\n        onClick={onRetry}\n        className={cn(\n          \"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg outline-none\",\n          \"transition-[background-color,scale] duration-150 hover:bg-hover active:scale-[0.97] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        )}\n      >\n        <Refresh size={14} className=\"text-fg-3\" />\n        Try again\n      </button>\n    </div>\n  );\n}\n\nfunction Avatar({ name, src, size = 40 }: { name: string; src?: string; size?: number }) {\n  const [loaded, setLoaded] = useState(false);\n  const [failed, setFailed] = useState(false);\n  const initials = name\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n  return (\n    <span className=\"relative grid shrink-0 place-items-center overflow-hidden rounded-full border border-line bg-frame text-[13px] font-medium text-fg-2\" style={{ width: size, height: size }}>\n      <span aria-hidden>{initials}</span>\n      {src && !failed && (\n        // eslint-disable-next-line @next/next/no-img-element -- a copied component can't assume next/image\n        <img\n          src={src}\n          alt=\"\"\n          width={size}\n          height={size}\n          onLoad={() => setLoaded(true)}\n          onError={() => setFailed(true)}\n          className={cn(\"absolute inset-0 size-full object-cover transition-opacity duration-200\", loaded ? \"opacity-100\" : \"opacity-0\")}\n        />\n      )}\n    </span>\n  );\n}\n\nexport type HoverCardPerson = {\n  name: string;\n  handle?: string;\n  avatar?: string;\n  /** Role and team, e.g. \"Staff engineer · Platform\". */\n  role?: string;\n  bio?: string;\n  /** IANA zone. Shown as their local time, which is what people actually want before messaging. */\n  timeZone?: string;\n  /** A short status, e.g. \"In a meeting until 3:30 PM\". */\n  status?: string;\n  /** Whether the status means they're free: a filled dot when true, hollow when false. */\n  available?: boolean;\n};\n\nexport type HoverCardProfileProps = React.ComponentProps<\"div\"> & {\n  /** Pass the person directly, or leave it out to use what the HoverCard's load returned. */\n  person?: HoverCardPerson;\n};\n\n/** A person: avatar, name, role, two lines of bio, their local time and status. */\nexport function HoverCardProfile({ person, className, ...rest }: HoverCardProfileProps) {\n  const { state } = useCtx();\n  const data = person ?? (state.data as HoverCardPerson | undefined);\n  if (!person && state.status === \"error\") return <Failed what=\"this profile\" onRetry={state.retry} />;\n  return (\n    <div className={cn(\"min-w-0\", className)} {...rest}>\n      <Swap loaded={!!data} skeleton={<ProfileSkeleton />}>\n        {data && <ProfileBody person={data} />}\n      </Swap>\n    </div>\n  );\n}\n\nfunction localTime(timeZone: string) {\n  try {\n    return new Intl.DateTimeFormat(undefined, { hour: \"numeric\", minute: \"2-digit\", timeZone }).format(new Date());\n  } catch {\n    return null;\n  }\n}\n\nfunction ProfileBody({ person }: { person: HoverCardPerson }) {\n  // Only ever rendered inside an open card, on the client, so reading the clock here is hydration-safe.\n  const [time] = useState(() => (person.timeZone ? localTime(person.timeZone) : null));\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex items-center gap-3\">\n        <Avatar name={person.name} src={person.avatar} />\n        <div className=\"min-w-0 flex-1 leading-[18px]\">\n          <p className=\"truncate text-[14px] font-medium tracking-[-0.015em] text-fg\">{person.name}</p>\n          <p className=\"truncate text-[12.5px] text-fg-3\">{[person.handle && `@${person.handle}`, person.role].filter(Boolean).join(\" · \")}</p>\n        </div>\n      </div>\n      {/* Always two lines tall, so a short bio and the skeleton occupy the same box. */}\n      <p className=\"line-clamp-2 min-h-9 text-[12.5px] leading-[18px] text-fg-2 text-pretty\">{person.bio}</p>\n      {(time || person.status) && (\n        <div className=\"flex min-w-0 flex-col gap-1 border-t border-line pt-2.5 text-[12px] text-fg-3\">\n          {time && (\n            <p className=\"flex items-center gap-1.5\">\n              <Clock size={14} className=\"shrink-0 text-fg-4\" />\n              <span>\n                <span className=\"tabular\">{time}</span> local time\n              </span>\n            </p>\n          )}\n          {person.status && (\n            <p className=\"flex min-w-0 items-center gap-1.5\">\n              <span className=\"grid size-3.5 shrink-0 place-items-center\" aria-hidden>\n                <span className={cn(\"size-1.5 rounded-full\", person.available ? \"bg-success\" : \"border border-fg-3\")} />\n              </span>\n              <span className=\"truncate\">{person.status}</span>\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction ProfileSkeleton() {\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex items-center gap-3\">\n        <Bone className=\"size-10 rounded-full\" />\n        <div className=\"flex flex-1 flex-col gap-2 py-0.5\">\n          <Bone className=\"h-3 w-28\" />\n          <Bone className=\"h-2.5 w-40\" />\n        </div>\n      </div>\n      <div className=\"flex min-h-9 flex-col justify-center gap-2\">\n        <Bone className=\"h-2.5 w-full\" />\n        <Bone className=\"h-2.5 w-3/4\" />\n      </div>\n      <div className=\"flex flex-col gap-2.5 border-t border-line pb-1 pt-3.5\">\n        <Bone className=\"h-2.5 w-28\" />\n        <Bone className=\"h-2.5 w-44\" />\n      </div>\n    </div>\n  );\n}\n\nexport type HoverCardLinkPreview = {\n  url: string;\n  title: string;\n  description?: string;\n  /** Shown at 16:9 above the text; the box is reserved before the image loads. */\n  image?: string;\n  siteName?: string;\n  /** Freshness, e.g. \"Updated 3 days ago\". */\n  meta?: string;\n};\n\nexport type HoverCardLinkProps = React.ComponentProps<\"div\"> & {\n  /** Pass the preview directly, or leave it out to use what the HoverCard's load returned. */\n  preview?: HoverCardLinkPreview;\n  /** Reserve the 16:9 image box in the skeleton, for previews that will have one. */\n  withImage?: boolean;\n};\n\n/** A link: optional image, the site, a two-line title and a two-line description. */\nexport function HoverCardLink({ preview, withImage = false, className, ...rest }: HoverCardLinkProps) {\n  const { state } = useCtx();\n  const data = preview ?? (state.data as HoverCardLinkPreview | undefined);\n  if (!preview && state.status === \"error\") return <Failed what=\"this preview\" onRetry={state.retry} />;\n  return (\n    <div className={cn(\"min-w-0\", className)} {...rest}>\n      <Swap loaded={!!data} skeleton={<LinkSkeleton withImage={withImage} />}>\n        {data && <LinkBody preview={data} />}\n      </Swap>\n    </div>\n  );\n}\n\nfunction hostOf(url: string) {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n}\n\nfunction LinkBody({ preview }: { preview: HoverCardLinkPreview }) {\n  const [loaded, setLoaded] = useState(false);\n  const [failed, setFailed] = useState(false);\n  const host = hostOf(preview.url);\n  return (\n    <div className=\"flex flex-col gap-2.5\">\n      {preview.image && (\n        <div className=\"relative -mx-1.5 -mt-1.5 aspect-video overflow-hidden rounded-lg border border-line bg-fg/[0.05]\">\n          {!failed && (\n            // eslint-disable-next-line @next/next/no-img-element -- a copied component can't assume next/image\n            <img\n              src={preview.image}\n              alt=\"\"\n              onLoad={() => setLoaded(true)}\n              onError={() => setFailed(true)}\n              className={cn(\"size-full object-cover transition-opacity duration-200\", loaded ? \"opacity-100\" : \"opacity-0\")}\n            />\n          )}\n        </div>\n      )}\n      <p className=\"flex min-w-0 items-center gap-1.5 text-[12px] text-fg-3\">\n        <Globe size={14} className=\"shrink-0 text-fg-4\" />\n        <span className=\"truncate\">{preview.siteName ? `${preview.siteName} · ${host}` : host}</span>\n      </p>\n      <div className=\"flex flex-col gap-1\">\n        <p className=\"line-clamp-2 text-[13.5px] font-medium leading-[19px] tracking-[-0.01em] text-fg text-balance\">{preview.title}</p>\n        {preview.description && <p className=\"line-clamp-2 min-h-9 text-[12.5px] leading-[18px] text-fg-2\">{preview.description}</p>}\n      </div>\n      {preview.meta && <p className=\"text-[11.5px] text-fg-4\">{preview.meta}</p>}\n    </div>\n  );\n}\n\nfunction LinkSkeleton({ withImage }: { withImage: boolean }) {\n  return (\n    <div className=\"flex flex-col gap-2.5\">\n      {withImage && <Bone className=\"-mx-1.5 -mt-1.5 aspect-video rounded-lg\" />}\n      <div className=\"flex h-[18px] items-center gap-1.5\">\n        <Bone className=\"size-3.5 rounded-full\" />\n        <Bone className=\"h-2.5 w-32\" />\n      </div>\n      <div className=\"flex flex-col gap-1\">\n        <div className=\"flex h-[19px] items-center\">\n          <Bone className=\"h-3 w-4/5\" />\n        </div>\n        <div className=\"flex min-h-9 flex-col justify-center gap-2\">\n          <Bone className=\"h-2.5 w-full\" />\n          <Bone className=\"h-2.5 w-2/3\" />\n        </div>\n      </div>\n      <div className=\"flex h-[17px] items-center\">\n        <Bone className=\"h-2 w-24\" />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/hover-card"
}
