{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-preview",
  "type": "registry:ui",
  "title": "Hover Preview",
  "description": "A list of names that opens a photo at the pointer. It trails the cursor with a little weight, leans as it moves, and wipes to the next photo from the direction you travel, like flipping through prints.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/hover-preview.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  useVelocity,\n  type AnimationPlaybackControlsWithThen,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * HOVER PREVIEW STORYBOARD\n *\n *  rest    plain text: a list of names and nothing else.\n *          Photos download quietly once the list nears the\n *          screen, so the first hover doesn't wait\n *  enter   point at a row: its photo opens at the pointer,\n *          growing from 80% as a mask opens out from the\n *          pointer, in 0.3s. The other rows fade to 30%\n *  follow  the photo trails the pointer on a spring, a beat\n *          behind, and leans up to 5° the way it's moving.\n *          It straightens as it catches up\n *  switch  point at another row: the photo stays, and the\n *          next one wipes over it from the way you moved:\n *          down the list, from the top; up, from the bottom.\n *          Inside the wipe it settles from a slight zoom.\n *          Moving fast stacks wipes like flipping prints\n *  leave   the photo shrinks and closes into the point it\n *          opened from, and every row comes back\n *  focus   Tab to a row: its photo opens over the row's\n *          middle and glides from row to row\n *  touch   no hover, so each row shows a thumbnail instead\n *  reduce  the photo opens where you point and stays put;\n *          rows crossfade, nothing trails or leans\n *\n *  The photo is one element moved by a transform from motion\n *  values, so moving the pointer never renders React. Opens\n *  and wipes run as WAAPI animations. A photo joins the stack\n *  only once it's downloaded and decoded, so a wipe never\n *  shows a blank frame.\n * ───────────────────────────────────────────────────────── */\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\nconst PREVIEW = {\n  follow: { stiffness: 150, damping: 18, mass: 0.6 }, // trails by about 0.13s at speed, with almost no overshoot\n  lean: { stiffness: 220, damping: 24 }, // smooths the tilt so it eases upright as the photo slows\n  leanSpeed: 1000, // px/s of travel for the full tilt\n  grow: 0.8, // scale the photo opens from and closes to\n  zoom: 1.15, // scale a wiped-in photo settles from\n  open: { duration: 0.3, ease: EASE_OUT },\n  close: { duration: 0.2, ease: EASE_OUT },\n  wipe: { duration: 0.5, ease: EASE_OUT },\n  settle: { duration: 0.7, ease: EASE_OUT }, // the zoom inside a wipe, a little longer than the wipe\n  fade: { duration: 0.2, ease: \"easeOut\" }, // reduced motion\n  stack: 4, // most photos layered at once while wipes overlap\n} as const;\n\nconst OPEN = \"inset(0% 0% 0% 0%)\";\n// Where a wipe starts: a sliver at the edge the new photo comes from.\nconst WIPE = { top: \"inset(0% 0% 100% 0%)\", bottom: \"inset(100% 0% 0% 0%)\" };\n\ntype Edge = keyof typeof WIPE;\ntype Point = { x: number; y: number };\ntype RowState = \"idle\" | \"active\" | \"dimmed\";\ntype Stack = { push: (src: string, edge: Edge) => void; clear: () => void };\n\n// Photos fetched ahead, by URL, shared by every list on the page. Holding the\n// element keeps the download in memory, so an <img> with the same URL is\n// complete the moment it mounts.\nconst photos = new Map<string, { image: HTMLImageElement; decoded?: Promise<void> }>();\n\nfunction fetchPhoto(src: string) {\n  let photo = photos.get(src);\n  if (!photo) {\n    const image = new Image();\n    image.fetchPriority = \"low\";\n    image.src = src;\n    photo = { image };\n    photos.set(src, photo);\n  }\n  return photo;\n}\n\n/** Resolves once `src` is downloaded and decoded, or has failed to. */\nfunction decodePhoto(src: string) {\n  const photo = fetchPhoto(src);\n  photo.decoded ??= photo.image.decode().catch(() => {});\n  return photo.decoded;\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\n/** Clamps a position to `0…max`, or centers it when there's no room. */\nconst within = (value: number, max: number) => (max <= 0 ? max / 2 : clamp(value, 0, max));\n\nconst percent = (part: number, whole: number) =>\n  whole > 0 ? Math.round(clamp(part / whole, 0, 1) * 1000) / 10 : 50;\n\n/**\n * Everything that changes while the pointer moves lives here, outside React:\n * which row is showing, where the photo is headed, and whether it's open. Rows\n * subscribe to their own state, so only the rows that change render.\n */\nfunction createPreview(x: MotionValue<number>, y: MotionValue<number>) {\n  const rows = new Map<string, { element: HTMLElement; image: string }>();\n  const listeners = new Set<() => void>();\n\n  let root: HTMLElement | null = null;\n  let plane: HTMLElement | null = null; // the list's box, which the photo moves within\n  let frame: HTMLElement | null = null;\n  let stack: Stack | null = null;\n  let reduce = false;\n  let active: string | null = null;\n  let last: string | null = null; // kept while closing, so a quick return wipes from the right side\n  let source: \"pointer\" | \"keyboard\" | null = null;\n  let anchor: Point = { x: 0, y: 0 }; // where the photo is centered, in client px\n  let bounds = { left: 0, top: 0, width: 0, height: 0 };\n  let size = { width: 0, height: 0 };\n  let phase: \"closed\" | \"open\" | \"closing\" = \"closed\";\n  let shut = \"inset(50% 50% 50% 50%)\"; // a clip shrunk to the anchor: opens from it, closes to it\n  let shutter: AnimationPlaybackControlsWithThen | null = null;\n  let settled = false; // fully open, so the origin can move without a jump\n  let request = 0; // bumps on every change, so a photo that loads late can't show for a row already left\n\n  const setActive = (id: string | null) => {\n    if (id === active) return;\n    active = id;\n    for (const listener of listeners) listener();\n  };\n\n  // Read once per row change and on scroll, never per pointer move.\n  const measure = () => {\n    if (plane) bounds = plane.getBoundingClientRect();\n    if (frame) size = { width: frame.offsetWidth, height: frame.offsetHeight };\n  };\n\n  // Centers the photo on the anchor, kept inside the list.\n  const place = (instant: boolean) => {\n    const left = within(anchor.x - bounds.left - size.width / 2, bounds.width - size.width);\n    const top = within(anchor.y - bounds.top - size.height / 2, bounds.height - size.height);\n    if (instant) {\n      x.jump(left);\n      y.jump(top);\n    } else {\n      x.set(left);\n      y.set(top);\n    }\n  };\n\n  // Closed, the photo waits exactly where it will open. Open, it trails the\n  // anchor; with reduced motion it stays where it opened.\n  const follow = () => {\n    if (phase === \"closed\") place(true);\n    else if (!reduce) place(false);\n  };\n\n  // Points the open or close at the anchor: where it falls on the photo,\n  // clamped to its edges. Mid-list that's the middle; near the list's edges\n  // the photo is held in, and when leaving the pointer is already past it.\n  const aim = (element: HTMLElement) => {\n    const ox = percent(anchor.x - bounds.left - x.get(), size.width);\n    const oy = percent(anchor.y - bounds.top - y.get(), size.height);\n    element.style.transformOrigin = `${ox}% ${oy}%`;\n    shut = `inset(${oy}% ${100 - ox}% ${100 - oy}% ${ox}%)`;\n  };\n\n  const open = () => {\n    const element = frame;\n    if (!element) return;\n    const from = phase;\n    phase = \"open\";\n    settled = false;\n\n    if (reduce) {\n      shutter = animate(\n        element,\n        from === \"closed\"\n          ? { opacity: [0, 1], clipPath: [OPEN, OPEN], transform: [\"scale(1)\", \"scale(1)\"] }\n          : { opacity: 1 },\n        PREVIEW.fade,\n      );\n      return;\n    }\n\n    let animation: AnimationPlaybackControlsWithThen;\n    if (from === \"closed\") {\n      aim(element);\n      animation = animate(\n        element,\n        {\n          opacity: [1, 1],\n          clipPath: [shut, OPEN],\n          transform: [`scale(${PREVIEW.grow})`, \"scale(1)\"],\n        },\n        PREVIEW.open,\n      );\n    } else {\n      // Caught while closing: open again from wherever it got to.\n      animation = animate(element, { clipPath: OPEN, transform: \"scale(1)\" }, PREVIEW.open);\n    }\n    shutter = animation;\n    animation.then(() => {\n      if (shutter === animation) settled = true;\n    });\n  };\n\n  const close = () => {\n    const element = frame;\n    if (!element) return;\n    phase = \"closing\";\n    // Shrink toward where the pointer left. Mid-open, keep the origin it's\n    // growing from, or the photo would jump.\n    if (settled && !reduce) aim(element);\n    settled = false;\n    const animation = (shutter = reduce\n      ? animate(element, { opacity: 0 }, PREVIEW.fade)\n      : animate(element, { clipPath: shut, transform: `scale(${PREVIEW.grow})` }, PREVIEW.close));\n    animation.then(() => {\n      if (shutter !== animation || phase !== \"closing\") return;\n      phase = \"closed\";\n      stack?.clear();\n    });\n  };\n\n  const show = (id: string, point: Point, from: \"pointer\" | \"keyboard\") => {\n    const row = rows.get(id);\n    if (!row) return;\n    const previous = rows.get(active ?? last ?? \"\");\n    source = from;\n    anchor = point;\n    measure();\n    // Reduced motion holds the photo still, but it can't be left behind: keyboard\n    // focus moves it to each row, and a row more than a photo's height away\n    // (down a long list, or after a scroll) brings it over. Both are jumps.\n    if (reduce && phase !== \"closed\") {\n      const far = Math.abs(anchor.y - bounds.top - y.get() - size.height / 2) > size.height;\n      if (from === \"keyboard\" || far) place(true);\n    } else {\n      follow();\n    }\n    setActive(id);\n    last = id;\n\n    const edge: Edge =\n      previous && previous.element.compareDocumentPosition(row.element) & Node.DOCUMENT_POSITION_PRECEDING\n        ? \"bottom\"\n        : \"top\";\n    const ticket = ++request;\n    decodePhoto(row.image).then(() => {\n      if (ticket !== request) return;\n      stack?.push(row.image, edge);\n      if (phase !== \"open\") open();\n    });\n  };\n\n  const hide = () => {\n    request += 1;\n    source = null;\n    setActive(null);\n    if (phase === \"open\") close();\n  };\n\n  return {\n    subscribe(listener: () => void) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    stateOf(id: string): RowState {\n      return active === null ? \"idle\" : active === id ? \"active\" : \"dimmed\";\n    },\n    register(id: string, element: HTMLElement | null, image: string) {\n      if (!element) return;\n      rows.set(id, { element, image });\n      return () => {\n        rows.delete(id);\n        if (active === id) hide();\n      };\n    },\n    setReduce(value: boolean) {\n      reduce = value;\n    },\n    enter(id: string, event: React.PointerEvent) {\n      if (event.pointerType === \"touch\") return;\n      show(id, { x: event.clientX, y: event.clientY }, \"pointer\");\n    },\n    move(event: React.PointerEvent) {\n      if (source !== \"pointer\" || event.pointerType === \"touch\") return;\n      anchor = { x: event.clientX, y: event.clientY };\n      follow();\n    },\n    leave(event: React.PointerEvent) {\n      if (source !== \"pointer\") return;\n      anchor = { x: event.clientX, y: event.clientY };\n      hide();\n    },\n    focus(id: string, element: HTMLElement) {\n      if (!element.matches(\":focus-visible\")) return;\n      const rect = element.getBoundingClientRect();\n      show(id, { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, \"keyboard\");\n    },\n    blur(event: React.FocusEvent) {\n      if (source !== \"keyboard\") return;\n      if (event.relatedTarget instanceof Node && root?.contains(event.relatedTarget)) return;\n      hide();\n    },\n    /** Hands over the elements the preview drives, and starts listening. */\n    connect(elements: {\n      root: HTMLElement | null;\n      plane: HTMLElement | null;\n      frame: HTMLElement | null;\n      stack: Stack | null;\n    }) {\n      ({ root, plane, frame, stack } = elements);\n\n      // The page can scroll under a resting pointer: keep the photo under it.\n      const refit = () => {\n        if (source !== \"pointer\") return;\n        measure();\n        follow();\n      };\n      window.addEventListener(\"scroll\", refit, { capture: true, passive: true });\n      window.addEventListener(\"resize\", refit);\n\n      // Fetch every photo once the list nears the screen. Screens that can't\n      // hover never show them, so they skip it.\n      let observer: IntersectionObserver | undefined;\n      if (root && window.matchMedia(\"(hover: hover)\").matches) {\n        observer = new IntersectionObserver(\n          (entries) => {\n            if (!entries.some((entry) => entry.isIntersecting)) return;\n            observer?.disconnect();\n            for (const row of rows.values()) fetchPhoto(row.image);\n          },\n          { rootMargin: \"50% 0px\" },\n        );\n        observer.observe(root);\n      }\n\n      return () => {\n        window.removeEventListener(\"scroll\", refit, { capture: true });\n        window.removeEventListener(\"resize\", refit);\n        observer?.disconnect();\n        root = plane = frame = stack = null;\n      };\n    },\n  };\n}\n\ntype Preview = ReturnType<typeof createPreview>;\n\nconst PreviewContext = React.createContext<Preview | null>(null);\n\nfunction usePreview() {\n  const preview = React.useContext(PreviewContext);\n  if (!preview) throw new Error(\"HoverPreviewItem must be used inside HoverPreview.\");\n  return preview;\n}\n\nexport interface HoverPreviewProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Size, shape and rounding of the floating photo. */\n  previewClassName?: string;\n  /** Most degrees the photo leans while it moves. `0` keeps it upright. */\n  tilt?: number;\n}\n\n/**\n * A list of `HoverPreviewItem` rows. Pointing at a row opens its photo at the\n * pointer; the photo stays inside this element's box.\n */\nexport function HoverPreview({\n  previewClassName,\n  tilt = 5,\n  className,\n  children,\n  onPointerMove,\n  onPointerLeave,\n  ...props\n}: HoverPreviewProps) {\n  const reduce = useReducedMotion() ?? false;\n  const x = useSpring(0, PREVIEW.follow);\n  const y = useSpring(0, PREVIEW.follow);\n  const lean = useSpring(\n    useTransform(useVelocity(x), (velocity) => clamp(velocity / PREVIEW.leanSpeed, -1, 1) * tilt),\n    PREVIEW.lean,\n  );\n  const [preview] = React.useState(() => createPreview(x, y));\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const planeRef = React.useRef<HTMLDivElement>(null);\n  const frameRef = React.useRef<HTMLDivElement>(null);\n  const stackRef = React.useRef<Stack>(null);\n\n  React.useEffect(() => preview.setReduce(reduce), [preview, reduce]);\n  React.useEffect(\n    () =>\n      preview.connect({\n        root: rootRef.current,\n        plane: planeRef.current,\n        frame: frameRef.current,\n        stack: stackRef.current,\n      }),\n    [preview],\n  );\n\n  return (\n    <PreviewContext.Provider value={preview}>\n      <div\n        ref={rootRef}\n        className={cn(\"relative\", className)}\n        onPointerMove={(event) => {\n          onPointerMove?.(event);\n          preview.move(event);\n        }}\n        onPointerLeave={(event) => {\n          onPointerLeave?.(event);\n          preview.leave(event);\n        }}\n        {...props}\n      >\n        <ul role=\"list\">{children}</ul>\n        <div ref={planeRef} aria-hidden className=\"pointer-events-none absolute inset-0 z-10\">\n          <motion.div\n            className=\"absolute top-0 left-0 will-change-transform\"\n            style={{ x, y, rotate: reduce ? 0 : lean }}\n          >\n            <div\n              ref={frameRef}\n              className={cn(\"relative aspect-[4/3] w-72 overflow-hidden rounded-lg\", previewClassName)}\n              style={{ clipPath: \"inset(50% 50% 50% 50%)\", transform: `scale(${PREVIEW.grow})` }}\n            >\n              <PhotoStack ref={stackRef} reduce={reduce} />\n            </div>\n          </motion.div>\n        </div>\n      </div>\n    </PreviewContext.Provider>\n  );\n}\n\nexport interface HoverPreviewItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\n  /** The photo that opens while this row is pointed at or focused. */\n  image: string;\n  /** A smaller copy for the thumbnail touch screens show instead. Defaults to `image`. */\n  thumbnail?: string;\n  /** Classes for the touch-screen thumbnail. It's hidden on screens that can hover. */\n  thumbnailClassName?: string;\n}\n\n/**\n * One row: a link. Lay out its contents with `className`. While another row\n * is showing, this row's direct children fade, so a border on the row itself\n * stays put; style that with `data-[state=dimmed]:*:…`.\n */\nexport function HoverPreviewItem({\n  image,\n  thumbnail,\n  thumbnailClassName,\n  className,\n  children,\n  onPointerEnter,\n  onFocus,\n  onBlur,\n  ...props\n}: HoverPreviewItemProps) {\n  const preview = usePreview();\n  const id = React.useId();\n  const ref = React.useRef<HTMLAnchorElement>(null);\n  const state = React.useSyncExternalStore(\n    preview.subscribe,\n    () => preview.stateOf(id),\n    () => \"idle\",\n  );\n\n  React.useLayoutEffect(() => preview.register(id, ref.current, image), [preview, id, image]);\n\n  return (\n    <li>\n      <a\n        {...props}\n        ref={ref}\n        data-state={state}\n        className={cn(\n          \"flex items-center gap-4\",\n          // Dim slowly, light up fast.\n          \"*:transition-opacity *:duration-300 *:ease-[cubic-bezier(0.23,1,0.32,1)] data-[state=active]:*:duration-150 data-[state=dimmed]:*:opacity-30\",\n          className,\n        )}\n        onPointerEnter={(event) => {\n          onPointerEnter?.(event);\n          preview.enter(id, event);\n        }}\n        onFocus={(event) => {\n          onFocus?.(event);\n          preview.focus(id, event.currentTarget);\n        }}\n        onBlur={(event) => {\n          onBlur?.(event);\n          preview.blur(event);\n        }}\n      >\n        <img\n          src={thumbnail ?? image}\n          alt=\"\"\n          loading=\"lazy\"\n          decoding=\"async\"\n          draggable={false}\n          className={cn(\n            \"hidden size-12 shrink-0 rounded-md object-cover [@media(hover:none)]:block\",\n            thumbnailClassName,\n          )}\n        />\n        {typeof children === \"string\" ? <span>{children}</span> : children}\n      </a>\n    </li>\n  );\n}\n\ntype Layer = { key: number; src: string; edge: Edge | null };\n\n/**\n * The photos inside the frame, newest on top. The first one is revealed by the\n * frame opening; every later one wipes in over the last, and once it covers\n * them they're dropped.\n */\nfunction PhotoStack({ ref, reduce }: { ref: React.Ref<Stack>; reduce: boolean }) {\n  const [layers, setLayers] = React.useState<Layer[]>([]);\n  const nextKey = React.useRef(0);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      push(src, edge) {\n        const key = ++nextKey.current;\n        setLayers((current) => {\n          if (current.at(-1)?.src === src) return current;\n          return [...current, { key, src, edge: current.length ? edge : null }].slice(-PREVIEW.stack);\n        });\n      },\n      clear() {\n        setLayers([]);\n      },\n    }),\n    [],\n  );\n\n  const covered = React.useCallback((key: number) => {\n    setLayers((current) => {\n      const index = current.findIndex((layer) => layer.key === key);\n      return index > 0 ? current.slice(index) : current;\n    });\n  }, []);\n\n  return layers.map((layer, index) => (\n    <Photo key={layer.key} layer={layer} bottom={index === 0} reduce={reduce} onCovered={covered} />\n  ));\n}\n\nfunction Photo({\n  layer,\n  bottom,\n  reduce,\n  onCovered,\n}: {\n  layer: Layer;\n  bottom: boolean;\n  reduce: boolean;\n  onCovered: (key: number) => void;\n}) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const imageRef = React.useRef<HTMLImageElement>(null);\n  const wipe = React.useRef<AnimationPlaybackControlsWithThen | null>(null);\n\n  // Starts before paint, so the photo never shows a frame un-wiped.\n  React.useLayoutEffect(() => {\n    const element = ref.current;\n    const image = imageRef.current;\n    if (!layer.edge || !element || !image) return;\n    let stopped = false;\n    const animation = reduce\n      ? animate(element, { opacity: [0, 1] }, PREVIEW.fade)\n      : animate(element, { clipPath: [WIPE[layer.edge], OPEN] }, PREVIEW.wipe);\n    const zoom = reduce\n      ? null\n      : animate(image, { transform: [`scale(${PREVIEW.zoom})`, \"scale(1)\"] }, PREVIEW.settle);\n    wipe.current = animation;\n    animation.then(() => {\n      if (!stopped) onCovered(layer.key);\n    });\n    return () => {\n      stopped = true;\n      animation.stop();\n      zoom?.stop();\n    };\n  }, [layer, reduce, onCovered]);\n\n  // Pushed to the bottom by a full stack mid-wipe: nothing is under it now,\n  // so finish the wipe at once rather than show the empty frame.\n  React.useEffect(() => {\n    if (bottom) wipe.current?.complete();\n  }, [bottom]);\n\n  return (\n    <div\n      ref={ref}\n      className=\"absolute inset-0\"\n      style={layer.edge ? (reduce ? { opacity: 0 } : { clipPath: WIPE[layer.edge] }) : undefined}\n    >\n      <img ref={imageRef} src={layer.src} alt=\"\" draggable={false} className=\"size-full object-cover\" />\n    </div>\n  );\n}\n"
    }
  ]
}