{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "zoom-image",
  "type": "registry:ui",
  "title": "Zoom Image",
  "description": "Click an image to zoom it from its exact on-page position to fit the viewport, in a native modal dialog.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/zoom-image.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface ZoomImageProps extends Omit<React.ComponentPropsWithoutRef<\"figure\">, \"children\"> {\n  src: string;\n  /** Required alternative text (used for the thumbnail, the zoomed image and the dialog name). */\n  alt: string;\n  /** Higher-resolution source for the zoomed view. Defaults to `src`. */\n  zoomSrc?: string;\n  /** Intrinsic size; reserves layout space and is the fallback when the natural size is unknown. */\n  width?: number;\n  height?: number;\n  /** Caption shown in the zoomed view (and under the thumbnail when `showCaption`). */\n  caption?: string;\n  showCaption?: boolean;\n  /** Crop the thumbnail to an aspect ratio (\"4/3\", \"16/9\"…); \"auto\" keeps the natural ratio. */\n  aspectRatio?: string;\n  /** Space kept around the zoomed image, in px. */\n  margin?: number;\n  /** Zoom transition length in ms. */\n  duration?: number;\n  /** Thumbnail corner radius in px. */\n  rounded?: number;\n  /** Backdrop tone: the page tone (light / dark aware) or always dark. */\n  backdrop?: \"theme\" | \"dark\";\n  /** Accessible name of the close button. */\n  closeLabel?: string;\n  onZoomChange?: (zoomed: boolean) => void;\n}\n\nconst EASE = \"cubic-bezier(0.2, 0.9, 0.1, 1)\";\nconst ZOOMED_RADIUS = 6;\nconst CAPTION_GAP = 14;\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * Click an image to zoom it from its exact on-page position to fit the viewport. The zoomed copy\n * lives in a native modal <dialog>; a FLIP transform plus an animated clip-path makes it grow out\n * of the (possibly cropped) thumbnail and shrink back into it. Closes on click, Escape or scroll.\n */\nexport function ZoomImage({\n  src,\n  alt,\n  zoomSrc,\n  width,\n  height,\n  caption,\n  showCaption = true,\n  aspectRatio = \"auto\",\n  margin = 40,\n  duration = 360,\n  rounded = 12,\n  backdrop = \"theme\",\n  closeLabel = \"Close\",\n  onZoomChange,\n  className = \"\",\n  style,\n  ...props\n}: ZoomImageProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const captionId = `${uid}-caption`;\n  const [zoomed, setZoomed] = React.useState(false);\n\n  const thumbRef = React.useRef<HTMLImageElement>(null);\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const zoomRef = React.useRef<HTMLImageElement>(null);\n  const backdropRef = React.useRef<HTMLDivElement>(null);\n  const chromeRef = React.useRef<HTMLDivElement>(null);\n  const captionRef = React.useRef<HTMLParagraphElement>(null);\n  const phase = React.useRef<\"closed\" | \"opening\" | \"open\" | \"closing\">(\"closed\");\n  const anims = React.useRef<Animation[]>([]);\n\n  /** Sizes and centres the zoomed image (and caption) for the current viewport. */\n  const layout = () => {\n    const thumb = thumbRef.current;\n    const img = zoomRef.current;\n    if (!thumb || !img) return;\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const rect = thumb.getBoundingClientRect();\n    const nw = thumb.naturalWidth || width || rect.width || 1;\n    const nh = thumb.naturalHeight || height || rect.height || 1;\n    const capEl = captionRef.current;\n    const capH = capEl ? capEl.offsetHeight + CAPTION_GAP : 0;\n    const m = Math.max(0, Math.min(margin, vw / 4, vh / 4));\n    const scale = Math.max(0.01, Math.min((vw - m * 2) / nw, (vh - m * 2 - capH) / nh));\n    const w = nw * scale;\n    const h = nh * scale;\n    const left = (vw - w) / 2;\n    const top = (vh - h - capH) / 2;\n    Object.assign(img.style, { width: `${w}px`, height: `${h}px`, left: `${left}px`, top: `${top}px` });\n    if (capEl) capEl.style.top = `${top + h + CAPTION_GAP}px`;\n  };\n\n  /** Keyframes that place the zoomed image exactly over the thumbnail (cropped like it). */\n  const thumbFrame = (): Keyframe => {\n    const thumb = thumbRef.current!.getBoundingClientRect();\n    const img = zoomRef.current!;\n    const fw = img.offsetWidth || 1;\n    const fh = img.offsetHeight || 1;\n    const fx = img.offsetLeft + fw / 2;\n    const fy = img.offsetTop + fh / 2;\n    // Cover-fit scale: the zoomed image covers the thumbnail box, the overflow is clipped away.\n    const s = Math.max(thumb.width / fw, thumb.height / fh);\n    const ix = Math.max(0, (fw - thumb.width / s) / 2);\n    const iy = Math.max(0, (fh - thumb.height / s) / 2);\n    const dx = thumb.left + thumb.width / 2 - fx;\n    const dy = thumb.top + thumb.height / 2 - fy;\n    return {\n      transform: `translate(${dx}px, ${dy}px) scale(${s})`,\n      clipPath: `inset(${iy}px ${ix}px round ${rounded / s}px)`,\n    };\n  };\n  const restFrame: Keyframe = { transform: \"translate(0px, 0px) scale(1)\", clipPath: `inset(0px 0px round ${ZOOMED_RADIUS}px)` };\n\n  const finishClose = () => {\n    anims.current.forEach((a) => a.cancel());\n    anims.current = [];\n    phase.current = \"closed\";\n    // Reveal the thumbnail in the same frame the copy disappears, so nothing flickers.\n    if (thumbRef.current) thumbRef.current.style.visibility = \"\";\n    if (dialogRef.current?.open) dialogRef.current.close(); // returns focus to the trigger\n    setZoomed(false);\n    onZoomChange?.(false);\n  };\n\n  const open = () => {\n    const dialog = dialogRef.current;\n    const thumb = thumbRef.current;\n    const img = zoomRef.current;\n    if (!dialog || !thumb || !img || phase.current !== \"closed\") return;\n    phase.current = \"opening\";\n    setZoomed(true);\n    onZoomChange?.(true);\n    dialog.showModal();\n    layout();\n    thumb.style.visibility = \"hidden\";\n    if (reduceMotion || duration <= 0 || typeof img.animate !== \"function\") {\n      phase.current = \"open\";\n      return;\n    }\n    const timing = { duration, easing: EASE, fill: \"both\" as const };\n    const zoom = img.animate([thumbFrame(), restFrame], timing);\n    anims.current = [\n      zoom,\n      backdropRef.current!.animate([{ opacity: 0 }, { opacity: 1 }], timing),\n      chromeRef.current!.animate([{ opacity: 0 }, { opacity: 1 }], { ...timing, delay: duration * 0.5, duration: duration * 0.6 }),\n    ];\n    zoom.onfinish = () => {\n      if (phase.current === \"opening\") phase.current = \"open\";\n    };\n  };\n\n  const close = () => {\n    const img = zoomRef.current;\n    if (!img || phase.current === \"closed\" || phase.current === \"closing\") return;\n    const wasOpening = phase.current === \"opening\";\n    phase.current = \"closing\";\n    if (reduceMotion || duration <= 0 || typeof img.animate !== \"function\") {\n      finishClose();\n      return;\n    }\n    if (wasOpening && anims.current.length) {\n      // Interrupted mid-zoom: play the same animations backwards from where they are.\n      anims.current.forEach((a) => a.reverse());\n      anims.current[0].onfinish = finishClose;\n      return;\n    }\n    anims.current.forEach((a) => a.cancel());\n    const timing = { duration: duration * 0.85, easing: EASE, fill: \"both\" as const };\n    const zoom = img.animate([restFrame, thumbFrame()], timing);\n    anims.current = [\n      zoom,\n      backdropRef.current!.animate([{ opacity: 1 }, { opacity: 0 }], timing),\n      chromeRef.current!.animate([{ opacity: 1 }, { opacity: 0 }], { ...timing, duration: duration * 0.3 }),\n    ];\n    zoom.onfinish = finishClose;\n  };\n\n  // Latest handlers for the window listeners below (assigned after commit, never during render).\n  const closeRef = React.useRef(close);\n  const layoutRef = React.useRef(layout);\n  React.useEffect(() => {\n    closeRef.current = close;\n    layoutRef.current = layout;\n  });\n\n  // While zoomed: scrolling the page closes, resizing re-fits.\n  React.useEffect(() => {\n    if (!zoomed) return;\n    const startY = window.scrollY;\n    const onScroll = () => {\n      if (Math.abs(window.scrollY - startY) > 8) closeRef.current();\n    };\n    const onResize = () => {\n      if (phase.current === \"open\") layoutRef.current();\n    };\n    window.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      window.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [zoomed]);\n\n  // Never leave the page inert if the component unmounts while zoomed.\n  React.useEffect(\n    () => () => {\n      anims.current.forEach((a) => a.cancel());\n      if (dialogRef.current?.open) dialogRef.current.close();\n    },\n    [],\n  );\n\n  const thumbStyle: React.CSSProperties = {\n    borderRadius: rounded,\n    aspectRatio: aspectRatio !== \"auto\" ? aspectRatio.replace(\"/\", \" / \") : width && height ? `${width} / ${height}` : undefined,\n  };\n\n  return (\n    <figure className={`m-0 ${className}`} style={style} {...props}>\n      <button\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={zoomed}\n        onClick={open}\n        className=\"group relative block w-full cursor-zoom-in overflow-hidden text-left outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-900 dark:focus-visible:outline-zinc-100\"\n        style={{ borderRadius: rounded }}\n      >\n        {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n        <img\n          ref={thumbRef}\n          src={src}\n          alt={alt}\n          width={width}\n          height={height}\n          draggable={false}\n          className=\"block h-auto w-full object-cover\"\n          style={thumbStyle}\n        />\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute right-2.5 top-2.5 grid size-7 place-items-center rounded-full bg-zinc-950/55 text-white opacity-0 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-100\"\n        >\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n            <path d=\"M9.5 2.5h4v4M6.5 13.5h-4v-4M13.5 2.5 9 7M2.5 13.5 7 9\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n        </span>\n        <span className=\"sr-only\">, enlarge image</span>\n      </button>\n\n      {caption && showCaption ? (\n        <figcaption className=\"mt-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">{caption}</figcaption>\n      ) : null}\n\n      <dialog\n        ref={dialogRef}\n        aria-label={caption ? undefined : alt}\n        aria-labelledby={caption ? captionId : undefined}\n        onCancel={(event) => {\n          // Escape: animate out instead of letting the browser close the dialog abruptly.\n          event.preventDefault();\n          close();\n        }}\n        onClose={() => {\n          // The browser can force-close (e.g. a second Escape); keep our state in step.\n          if (phase.current !== \"closed\") finishClose();\n        }}\n        onClick={close}\n        onWheel={close}\n        onTouchMove={close}\n        className=\"fixed inset-0 m-0 h-full max-h-none w-full max-w-none cursor-zoom-out overflow-hidden border-0 bg-transparent p-0 outline-none backdrop:bg-transparent\"\n      >\n        <div\n          ref={backdropRef}\n          aria-hidden=\"true\"\n          className={`absolute inset-0 ${backdrop === \"dark\" ? \"bg-black/85\" : \"bg-[#F5F3EE]/92 dark:bg-[#0b0d0f]/92\"}`}\n        />\n        {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n        <img\n          ref={zoomRef}\n          src={zoomSrc ?? src}\n          alt={alt}\n          draggable={false}\n          className=\"absolute max-w-none origin-center select-none shadow-[0_30px_80px_-30px_rgb(0_0_0/0.45)] will-change-transform\"\n          style={{ clipPath: `inset(0px 0px round ${ZOOMED_RADIUS}px)` }}\n        />\n        <div ref={chromeRef}>\n          {caption ? (\n            <p\n              ref={captionRef}\n              id={captionId}\n              className={`absolute left-1/2 w-max max-w-[min(92vw,640px)] -translate-x-1/2 px-4 text-center text-sm leading-snug tracking-tight ${backdrop === \"dark\" ? \"text-zinc-300\" : \"text-zinc-700 dark:text-zinc-300\"}`}\n            >\n              {caption}\n            </p>\n          ) : null}\n          <button\n            type=\"button\"\n            aria-label={closeLabel}\n            className={`absolute right-4 top-4 inline-flex h-8 items-center gap-2 rounded-full border px-3 font-mono text-[10px] uppercase tracking-[0.14em] outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 ${backdrop === \"dark\" ? \"border-white/20 text-zinc-200 hover:bg-white/10 focus-visible:outline-white\" : \"border-zinc-900/15 text-zinc-700 hover:bg-zinc-900/5 focus-visible:outline-zinc-900 dark:border-white/20 dark:text-zinc-200 dark:hover:bg-white/10 dark:focus-visible:outline-white\"}`}\n          >\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3\">\n              <path d=\"M4 4l8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n            </svg>\n            Esc\n          </button>\n        </div>\n      </dialog>\n    </figure>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/zoom-image"
}
