{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-preview",
  "title": "Hover Preview",
  "description": "A preview that follows the pointer over a list of links. Off on touch, reachable by keyboard, and never the only way to anything.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/hover-preview.tsx",
      "content": "import type * as React from \"react\"\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { AnimatePresence, useReducedMotion } from \"motion/react\"\nimport {\n  keyboardAnchor,\n  useFineHover,\n  usePreviewDismiss,\n  usePreviewMotion,\n  type Point,\n  type PreviewMotion,\n} from \"@/registry/miseui/ui/hover-preview-state\"\nimport { PreviewFloat, type HoverPreviewContent, type HoverPreviewVariant } from \"@/registry/miseui/ui/hover-preview-surface\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype HoverPreviewEffect = \"tilt\"\n\ninterface HoverPreviewContextValue {\n  onEnter: (id: string, content: HoverPreviewContent, point: Point) => void\n  onMove: (id: string, content: HoverPreviewContent, point: Point) => void\n  onLeave: () => void\n  onFocusLink: (id: string, content: HoverPreviewContent, point: Point) => void\n}\n\nconst HoverPreviewContext = createContext<HoverPreviewContextValue | null>(null)\n\ntype HoverPreviewProps = React.ComponentProps<\"ul\"> & {\n  /** `card` picture and words, `media` the picture alone, `tag` one line riding beside the pointer. */\n  variant?: HoverPreviewVariant\n  /** `tilt` leans the preview into the direction of travel. */\n  effect?: HoverPreviewEffect\n}\n\nconst HIDE_DELAY_MS = 60\n\nfunction useHoverPreviewState(fineHover: boolean, movement: PreviewMotion) {\n  const [shown, setShown] = useState<{ id: string; content: HoverPreviewContent } | null>(null)\n  const currentId = useRef<string | null>(null)\n  const dismissed = useRef(false)\n  const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n  const hide = useCallback(() => {\n    clearTimeout(hideTimer.current)\n    currentId.current = null\n    setShown(null)\n  }, [])\n  const onEscape = useCallback(() => {\n    dismissed.current = true\n    hide()\n  }, [hide])\n  usePreviewDismiss({ visible: shown !== null, onScroll: hide, onEscape })\n  useEffect(() => () => clearTimeout(hideTimer.current), [])\n\n  const context = useMemo<HoverPreviewContextValue>(() => {\n    const show = (id: string, content: HoverPreviewContent, point: Point) => {\n      clearTimeout(hideTimer.current)\n      if (currentId.current === null) movement.jumpTo(point)\n      currentId.current = id\n      setShown({ id, content })\n    }\n    return {\n      onEnter: (id, content, point) => {\n        if (!fineHover) return\n        dismissed.current = false\n        show(id, content, point)\n      },\n      onMove: (id, content, point) => {\n        if (!fineHover || dismissed.current) return\n        if (currentId.current !== id) show(id, content, point)\n        movement.follow(point)\n      },\n      // Leaving one row and entering the next are separate events, so the hide waits a moment or the card would blink between rows.\n      onLeave: () => {\n        dismissed.current = false\n        clearTimeout(hideTimer.current)\n        hideTimer.current = setTimeout(hide, HIDE_DELAY_MS)\n      },\n      onFocusLink: (id, content, point) => {\n        if (!fineHover) return\n        movement.jumpTo(point)\n        show(id, content, point)\n      },\n    }\n  }, [fineHover, hide, movement])\n  return { shown, context }\n}\n\nfunction HoverPreview({ variant = \"card\", effect, className, children, ...props }: HoverPreviewProps) {\n  const fineHover = useFineHover()\n  const reduceMotion = useReducedMotion() ?? false\n  const movement = usePreviewMotion(effect === \"tilt\" && !reduceMotion)\n  const { shown, context } = useHoverPreviewState(fineHover, movement)\n  return (\n    <HoverPreviewContext.Provider value={context}>\n      <ul data-slot=\"hover-preview\" data-variant={variant} data-effect={effect} className={cn(\"grid min-w-0\", className)} {...props}>\n        {children}\n      </ul>\n      {fineHover\n        ? createPortal(\n            <AnimatePresence>\n              {shown ? (\n                <PreviewFloat key=\"hover-preview\" variant={variant} content={shown.content} x={movement.x} y={movement.y} rotate={movement.rotate} onSurface={movement.setSurface} />\n              ) : null}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </HoverPreviewContext.Provider>\n  )\n}\n\nconst LINK_CLASS = [\n  \"flex min-h-12 min-w-0 items-center justify-between gap-4 rounded-sm py-2 text-[15px] font-medium outline-none\",\n  \"transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:text-muted-foreground\",\n  \"focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n]\n\ntype HoverPreviewLinkProps = Omit<React.ComponentProps<\"a\">, \"title\"> & {\n  /** What floats beside the pointer. It repeats or adds to the row and is never the only way to anything. */\n  preview: HoverPreviewContent\n  /** A short line at the end of the row, such as a date. */\n  aside?: React.ReactNode\n}\n\nfunction HoverPreviewLink({ preview, aside, className, children, href, onPointerEnter, onPointerMove, onPointerLeave, onFocus, onBlur, ...props }: HoverPreviewLinkProps) {\n  const context = useContext(HoverPreviewContext)\n  if (!context) throw new Error(\"HoverPreviewLink must be used inside <HoverPreview>\")\n  const id = href ?? String(children)\n  const point = (event: React.PointerEvent): Point => ({ x: event.clientX, y: event.clientY })\n  return (\n    <li className=\"min-w-0 border-b last:border-b-0\">\n      <a\n        data-slot=\"hover-preview-link\"\n        href={href}\n        className={cn(LINK_CLASS, className)}\n        onPointerEnter={(event) => {\n          onPointerEnter?.(event)\n          if (event.pointerType !== \"touch\") context.onEnter(id, preview, point(event))\n        }}\n        onPointerMove={(event) => {\n          onPointerMove?.(event)\n          if (event.pointerType !== \"touch\") context.onMove(id, preview, point(event))\n        }}\n        onPointerLeave={(event) => {\n          onPointerLeave?.(event)\n          context.onLeave()\n        }}\n        onFocus={(event) => {\n          onFocus?.(event)\n          // Clicking a link also focuses it; only someone tabbing here is asking for the card this way.\n          if (event.currentTarget.matches(\":focus-visible\")) context.onFocusLink(id, preview, keyboardAnchor(event.currentTarget.getBoundingClientRect()))\n        }}\n        onBlur={(event) => {\n          onBlur?.(event)\n          context.onLeave()\n        }}\n        {...props}\n      >\n        <span className=\"min-w-0\">{children}</span>\n        {aside ? <span className=\"shrink-0 text-[13px] font-normal text-muted-foreground tabular-nums\">{aside}</span> : null}\n      </a>\n    </li>\n  )\n}\n\nexport {\n  HoverPreview,\n  HoverPreviewLink,\n  type HoverPreviewProps,\n  type HoverPreviewLinkProps,\n  type HoverPreviewContent,\n  type HoverPreviewVariant,\n  type HoverPreviewEffect,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/hover-preview-state.ts",
      "content": "import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\"\nimport { animate, useMotionValue, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\n\nconst FINE_HOVER = \"(hover: hover) and (pointer: fine)\"\nconst POINTER_GAP_PX = 24\nconst VIEWPORT_MARGIN_PX = 16\nconst TILT_PER_PX = 0.6\nconst MAX_TILT_DEG = 8\nconst TILT_SETTLE_MS = 120\nconst FALLBACK_SIZE = { width: 288, height: 200 }\nconst KEYBOARD_ANCHOR_INSET_PX = 240\n\nexport interface Point {\n  x: number\n  y: number\n}\n\ninterface Size {\n  width: number\n  height: number\n}\n\n/** Beside the pointer, flipped near the right edge, never off the top or bottom. */\nexport function previewPosition(pointer: Point, size: Size, viewport: Size): Point {\n  const right = pointer.x + POINTER_GAP_PX\n  const fitsRight = right + size.width <= viewport.width - VIEWPORT_MARGIN_PX\n  const x = fitsRight ? right : pointer.x - POINTER_GAP_PX - size.width\n  const maxY = Math.max(VIEWPORT_MARGIN_PX, viewport.height - size.height - VIEWPORT_MARGIN_PX)\n  const y = Math.min(maxY, Math.max(VIEWPORT_MARGIN_PX, pointer.y - size.height / 2))\n  return { x: Math.max(VIEWPORT_MARGIN_PX, x), y }\n}\n\n/** The card leans into the direction of travel, which is what makes it feel carried along and not attached. */\nexport function tiltFor(deltaX: number) {\n  return Math.max(-MAX_TILT_DEG, Math.min(MAX_TILT_DEG, deltaX * TILT_PER_PX))\n}\n\n/** A keyboard has no pointer, so the card sits as if the pointer were on the link's text. */\nexport function keyboardAnchor(rect: { left: number; top: number; width: number; height: number }): Point {\n  return { x: rect.left + Math.min(rect.width, KEYBOARD_ANCHOR_INSET_PX), y: rect.top + rect.height / 2 }\n}\n\nfunction subscribeHover(onChange: () => void) {\n  const query = window.matchMedia(FINE_HOVER)\n  query.addEventListener(\"change\", onChange)\n  return () => query.removeEventListener(\"change\", onChange)\n}\n\n/** False on touch: a finger has no hover, so nothing is set up there at all. */\nexport function useFineHover() {\n  return useSyncExternalStore(subscribeHover, () => window.matchMedia(FINE_HOVER).matches, () => false)\n}\n\nexport function usePreviewMotion(tilt: boolean) {\n  const reduceMotion = useReducedMotion() ?? false\n  const x = useMotionValue(0)\n  const y = useMotionValue(0)\n  const rotate = useMotionValue(0)\n  const [surface, setSurface] = useState<HTMLElement | null>(null)\n  const last = useRef<Point | null>(null)\n  const settle = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n  const target = useCallback(\n    (pointer: Point) =>\n      previewPosition(pointer, surface ? { width: surface.offsetWidth, height: surface.offsetHeight } : FALLBACK_SIZE, {\n        width: window.innerWidth,\n        height: window.innerHeight,\n      }),\n    [surface],\n  )\n\n  /** Starts where the pointer is. Animating from the previous spot would fly it in from a corner of the screen. */\n  const jumpTo = useCallback(\n    (pointer: Point) => {\n      const place = target(pointer)\n      x.set(place.x)\n      y.set(place.y)\n      rotate.set(0)\n      last.current = pointer\n    },\n    [rotate, target, x, y],\n  )\n\n  const follow = useCallback(\n    (pointer: Point) => {\n      const place = target(pointer)\n      const before = last.current\n      last.current = pointer\n      if (reduceMotion) {\n        x.set(place.x)\n        y.set(place.y)\n        return\n      }\n      animate(x, place.x, SPRING_DEFAULT)\n      animate(y, place.y, SPRING_DEFAULT)\n      if (!tilt || !before) return\n      animate(rotate, tiltFor(pointer.x - before.x), SPRING_DEFAULT)\n      clearTimeout(settle.current)\n      settle.current = setTimeout(() => animate(rotate, 0, SPRING_DEFAULT), TILT_SETTLE_MS)\n    },\n    [reduceMotion, rotate, target, tilt, x, y],\n  )\n\n  useEffect(() => () => clearTimeout(settle.current), [])\n\n  // The real size is only known once the portal has mounted, so the first placement is corrected here, before paint.\n  useLayoutEffect(() => {\n    if (!surface || !last.current) return\n    const place = target(last.current)\n    x.set(place.x)\n    y.set(place.y)\n  }, [surface, target, x, y])\n\n  return useMemo(() => ({ x, y, rotate, setSurface, jumpTo, follow }), [x, y, rotate, jumpTo, follow])\n}\n\nexport type PreviewMotion = ReturnType<typeof usePreviewMotion>\n\ninterface DismissOptions {\n  visible: boolean\n  onScroll: () => void\n  onEscape: () => void\n}\n\n/** WCAG 1.4.13: content shown on hover has to be dismissible without moving the pointer. */\nexport function usePreviewDismiss({ visible, onScroll, onEscape }: DismissOptions) {\n  useEffect(() => {\n    if (!visible) return\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") onEscape()\n    }\n    window.addEventListener(\"keydown\", onKey)\n    // A page that scrolls under a still pointer has moved the link away from the card.\n    window.addEventListener(\"scroll\", onScroll, { passive: true, capture: true })\n    return () => {\n      window.removeEventListener(\"keydown\", onKey)\n      window.removeEventListener(\"scroll\", onScroll, { capture: true })\n    }\n  }, [visible, onScroll, onEscape])\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/hover-preview-surface.tsx",
      "content": "import type * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport type { PreviewMotion } from \"@/registry/miseui/ui/hover-preview-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nexport type HoverPreviewVariant = \"card\" | \"media\" | \"tag\"\n\nexport interface HoverPreviewContent {\n  title?: string\n  text?: string\n  meta?: string\n  media?: React.ReactNode\n}\n\nconst SURFACE_CLASS: Record<HoverPreviewVariant, string> = {\n  card: \"w-72 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-[0_16px_40px_-16px_rgb(0_0_0/0.5)]\",\n  media: \"aspect-[4/3] w-64 overflow-hidden rounded-xl bg-muted shadow-[0_16px_40px_-16px_rgb(0_0_0/0.5)] ring-1 ring-foreground/10\",\n  tag: \"max-w-64 rounded-md bg-foreground px-2 py-1 text-[13px] text-background shadow-[0_8px_16px_-8px_rgb(0_0_0/0.5)]\",\n}\n\nfunction PreviewBody({ variant, content }: { variant: HoverPreviewVariant; content: HoverPreviewContent }) {\n  if (variant === \"tag\") {\n    return <span data-slot=\"hover-preview-tag\">{[content.title, content.meta].filter(Boolean).join(\", \")}</span>\n  }\n  if (variant === \"media\") return <div data-slot=\"hover-preview-media\" className=\"size-full\">{content.media}</div>\n  return (\n    <>\n      {content.media ? <div className=\"aspect-video overflow-hidden bg-muted\">{content.media}</div> : null}\n      <div className=\"grid gap-1 p-4\">\n        {content.meta ? <p className=\"text-[13px] text-muted-foreground\">{content.meta}</p> : null}\n        {content.title ? <p className=\"text-[15px] leading-snug font-semibold tracking-tight\">{content.title}</p> : null}\n        {content.text ? <p className=\"line-clamp-3 text-[13px] leading-snug text-muted-foreground\">{content.text}</p> : null}\n      </div>\n    </>\n  )\n}\n\ninterface PreviewFloatProps extends Pick<PreviewMotion, \"x\" | \"y\" | \"rotate\"> {\n  variant: HoverPreviewVariant\n  content: HoverPreviewContent\n  onSurface: (element: HTMLElement | null) => void\n}\n\nexport function PreviewFloat({ variant, content, x, y, rotate, onSurface }: PreviewFloatProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  return (\n    <motion.div\n      ref={onSurface}\n      aria-hidden=\"true\"\n      data-slot=\"hover-preview-surface\"\n      data-kind={variant}\n      className={cn(\"pointer-events-none fixed top-0 left-0 z-50\", SURFACE_CLASS[variant])}\n      style={{ x, y, rotate }}\n      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.92 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.92 }}\n      transition={reduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n    >\n      <PreviewBody variant={variant} content={content} />\n    </motion.div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "overlays"
  ],
  "type": "registry:ui"
}