{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hover-image-list",
  "type": "registry:ui",
  "title": "Hover Image List",
  "description": "An editorial index list whose rows reveal a floating image that follows the pointer, tilts with velocity and crossfades.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/hover-image-list.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useMotionValue, useSpring, useTransform, useVelocity } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface HoverImageListItem {\n  id: string;\n  title: string;\n  /** Small mono line on the right, e.g. \"Branding · 2026\". */\n  meta?: string;\n  /** Image URL or data URI revealed while the row is hovered or focused. */\n  image: string;\n  /** Rows with an href render links; rows without one render buttons that call `onSelect`. */\n  href?: string;\n}\n\nexport interface HoverImageListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  items: HoverImageListItem[];\n  /** Width of the floating image in px. */\n  imageWidth?: number;\n  /** Aspect ratio of the floating image (width / height), e.g. \"4/5\". */\n  ratio?: \"4/5\" | \"1/1\" | \"16/10\";\n  /** Spring the image uses to follow the pointer. */\n  spring?: Spring;\n  /** Maximum tilt in degrees, driven by horizontal pointer velocity. */\n  maxRotate?: number;\n  /** Crossfade between row images, in ms. */\n  crossfade?: number;\n  /** Dim the other rows while one is active. */\n  dimOthers?: boolean;\n  /** Show a zero-padded index at the start of each row. */\n  showIndex?: boolean;\n  /** Accent for the underline, arrow and focus ring. */\n  accent?: string;\n  /** Called when a row without an href is activated. */\n  onSelect?: (item: HoverImageListItem) => void;\n}\n\nconst RATIOS = { \"4/5\": 4 / 5, \"1/1\": 1, \"16/10\": 16 / 10 } as const;\n\nfunction subscribeMedia(query: string) {\n  return (cb: () => void) => {\n    const mq = window.matchMedia(query);\n    mq.addEventListener(\"change\", cb);\n    return () => mq.removeEventListener(\"change\", cb);\n  };\n}\nconst subscribeReducedMotion = subscribeMedia(\"(prefers-reduced-motion: reduce)\");\nconst subscribeNoHover = subscribeMedia(\"(hover: none)\");\n\n/** SSR-safe media queries: hydrate as \"no preference\" / \"can hover\", then follow the device. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\nfunction useNoHover() {\n  return React.useSyncExternalStore(subscribeNoHover, () => window.matchMedia(\"(hover: none)\").matches, () => false);\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n\n/**\n * An editorial index list. Hovering a row reveals a floating image that follows the pointer on a\n * spring, tilts with horizontal velocity and crossfades between rows. Keyboard focus (and reduced\n * motion) anchors the image beside the active row instead; touch screens get inline thumbnails.\n */\nexport function HoverImageList({\n  items,\n  imageWidth = 240,\n  ratio = \"4/5\",\n  spring = { stiffness: 220, damping: 24, mass: 0.6 },\n  maxRotate = 8,\n  crossfade = 280,\n  dimOthers = true,\n  showIndex = true,\n  accent = \"#FF7448\",\n  onSelect,\n  className = \"\",\n  style,\n  onPointerLeave,\n  ...props\n}: HoverImageListProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const noHover = useNoHover();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const visibleRef = React.useRef(false);\n  const [activeId, setActiveId] = React.useState<string | null>(null);\n\n  const imageHeight = Math.round(imageWidth / RATIOS[ratio]);\n\n  // Pointer target (raw) and the sprung position the image actually renders at.\n  const rawX = useMotionValue(0);\n  const rawY = useMotionValue(0);\n  const x = useSpring(rawX, spring);\n  const y = useSpring(rawY, spring);\n  // Tilt from horizontal velocity, smoothed by a second spring.\n  const velocityX = useVelocity(x);\n  const tilt = useTransform(velocityX, (v) => (reduceMotion ? 0 : clamp(v / 90, -maxRotate, maxRotate)));\n  const rotate = useSpring(tilt, { stiffness: 300, damping: 30 });\n\n  /** Root rect plus the scale of any transformed ancestor (e.g. a zoomed preview stage). */\n  const frame = () => {\n    const root = rootRef.current;\n    if (!root) return null;\n    const rect = root.getBoundingClientRect();\n    const scale = root.offsetWidth ? rect.width / root.offsetWidth || 1 : 1;\n    return { rect, scale, width: root.offsetWidth };\n  };\n\n  const place = (left: number, top: number, instant: boolean) => {\n    rawX.set(left);\n    rawY.set(top);\n    if (instant) {\n      x.jump(left);\n      y.jump(top);\n    }\n  };\n\n  /** Centre the image on the pointer. Jumps there when it was hidden, so it never flies in from a corner. */\n  const follow = (clientX: number, clientY: number) => {\n    const f = frame();\n    if (!f) return;\n    const localX = (clientX - f.rect.left) / f.scale;\n    const localY = (clientY - f.rect.top) / f.scale;\n    place(localX - imageWidth / 2, localY - imageHeight / 2, !visibleRef.current);\n  };\n\n  /** Anchor the image beside a row: right of centre, vertically centred on the row. */\n  const anchor = (row: HTMLElement, instant: boolean) => {\n    const f = frame();\n    if (!f) return;\n    const left = clamp(f.width * 0.64 - imageWidth / 2, 0, Math.max(0, f.width - imageWidth));\n    const top = row.offsetTop + row.offsetHeight / 2 - imageHeight / 2;\n    place(left, top, instant || !visibleRef.current);\n  };\n\n  const activate = (id: string) => {\n    setActiveId(id);\n    visibleRef.current = true;\n  };\n  const deactivate = () => {\n    setActiveId(null);\n    visibleRef.current = false;\n  };\n\n  const interactive = !noHover;\n  const visible = interactive && activeId !== null;\n  const activeIndex = items.findIndex((item) => item.id === activeId);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative w-full ${className}`}\n      style={{ [\"--hil-accent\" as string]: accent, ...style }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        if (event.pointerType !== \"mouse\") return;\n        // Keep the image while a row still holds keyboard focus.\n        const focused = document.activeElement;\n        if (focused && rootRef.current?.contains(focused) && focused.matches(\":focus-visible\")) return;\n        deactivate();\n      }}\n      {...props}\n    >\n      <ul className=\"border-b border-zinc-900/10 dark:border-white/10\">\n        {items.map((item, index) => {\n          const active = item.id === activeId;\n          const dimmed = dimOthers && activeId !== null && !active;\n          const rowClass = `group/row relative flex w-full items-center gap-4 py-5 text-left outline-hidden transition-opacity duration-300 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-solid focus-visible:outline-[var(--hil-accent)] sm:gap-6 ${dimmed ? \"opacity-40\" : \"opacity-100\"}`;\n          const handlers = {\n            onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {\n              if (event.pointerType !== \"mouse\") return;\n              if (reduceMotion) anchor(event.currentTarget, false);\n              else follow(event.clientX, event.clientY);\n              activate(item.id);\n            },\n            onPointerMove: (event: React.PointerEvent<HTMLElement>) => {\n              if (event.pointerType !== \"mouse\" || reduceMotion) return;\n              follow(event.clientX, event.clientY);\n            },\n            onFocus: (event: React.FocusEvent<HTMLElement>) => {\n              // Keyboard focus: anchor the image beside the row instead of following a pointer.\n              if (!event.currentTarget.matches(\":focus-visible\")) return;\n              anchor(event.currentTarget, reduceMotion);\n              activate(item.id);\n            },\n            onBlur: (event: React.FocusEvent<HTMLElement>) => {\n              if (!rootRef.current?.contains(event.relatedTarget as Node | null)) deactivate();\n            },\n          };\n          const content = (\n            <>\n              {noHover ? (\n                // Touch screens: an inline thumbnail replaces the floating image.\n                <span aria-hidden=\"true\" className=\"block w-14 shrink-0 overflow-hidden rounded-md border border-zinc-900/10 bg-zinc-100 dark:border-white/10 dark:bg-zinc-800\" style={{ aspectRatio: ratio }}>\n                  {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n                  <img src={item.image} alt=\"\" draggable={false} className=\"block size-full object-cover\" />\n                </span>\n              ) : null}\n              {showIndex ? (\n                <span aria-hidden=\"true\" className=\"w-7 shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 tabular-nums dark:text-zinc-500\">\n                  {String(index + 1).padStart(2, \"0\")}\n                </span>\n              ) : null}\n              <span className={`relative min-w-0 flex-1 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${active ? \"translate-x-2 sm:translate-x-3\" : \"\"}`}>\n                <span className=\"relative inline-block max-w-full truncate align-bottom text-2xl font-medium tracking-[-0.03em] text-zinc-900 sm:text-[32px] sm:leading-tight dark:text-zinc-50\">\n                  {item.title}\n                  <span\n                    aria-hidden=\"true\"\n                    className={`absolute inset-x-0 -bottom-0.5 h-px origin-left bg-[var(--hil-accent)] transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${active ? \"scale-x-100\" : \"scale-x-0\"}`}\n                  />\n                </span>\n              </span>\n              {item.meta ? (\n                <span className=\"hidden shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 sm:block dark:text-zinc-400\">{item.meta}</span>\n              ) : null}\n              <svg\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                aria-hidden=\"true\"\n                className={`size-4 shrink-0 text-[var(--hil-accent)] transition-[opacity,translate] duration-300 ${active ? \"translate-x-0 opacity-100\" : \"-translate-x-2 opacity-0\"}`}\n              >\n                <path d=\"M3 8h10M9 4l4 4-4 4\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n              </svg>\n            </>\n          );\n          return (\n            <li key={item.id} className=\"border-t border-zinc-900/10 dark:border-white/10\">\n              {item.href ? (\n                <a href={item.href} className={rowClass} {...handlers}>\n                  {content}\n                </a>\n              ) : (\n                <button type=\"button\" className={rowClass} onClick={() => onSelect?.(item)} {...handlers}>\n                  {content}\n                </button>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n\n      {/* Floating image: purely decorative (the row text is the accessible name), never intercepts the pointer. */}\n      {interactive ? (\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 z-10 overflow-hidden rounded-lg bg-zinc-200 shadow-[0_30px_60px_-30px_rgb(0_0_0/0.5)] dark:bg-zinc-800\"\n          style={{ x, y, rotate, width: imageWidth, height: imageHeight }}\n          initial={false}\n          animate={visible ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduceMotion ? 1 : 0.86 }}\n          transition={reduceMotion ? { duration: 0.15 } : { type: \"spring\", stiffness: 380, damping: 32 }}\n        >\n          {items.map((item, index) => (\n            // eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component\n            <img\n              key={item.id}\n              src={item.image}\n              alt=\"\"\n              draggable={false}\n              className=\"absolute inset-0 block size-full object-cover transition-[opacity,scale] ease-out\"\n              style={{\n                opacity: index === activeIndex ? 1 : 0,\n                scale: index === activeIndex || reduceMotion ? 1 : 1.08,\n                transitionDuration: `${crossfade}ms`,\n              }}\n            />\n          ))}\n        </motion.div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/hover-image-list"
}
