{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "zoom-pan-image",
  "type": "registry:ui",
  "title": "Zoom Pan Image",
  "description": "Pinch, wheel or double-click to zoom where you point, then throw it around.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@number-flow/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/zoom-pan-image.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Minus, Plus } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\n\ntype Point = { x: number; y: number };\ntype Geo = { cw: number; ch: number; ox: number; oy: number; fw: number; fh: number };\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n// Past an edge the content follows the finger less and less, like a scroll view at its end.\nconst band = (over: number, size: number) => (over * size * 0.55) / (size + 0.55 * over);\nconst rubber = (v: number, lo: number, hi: number, size: number) => (v < lo ? lo - band(lo - v, size) : v > hi ? hi + band(v - hi, size) : v);\n\n// Where the content may sit at a given scale. Narrower than the viewport: centered. Wider: its edges\n// may not come inside the viewport's. The content is laid out at (ox, oy) and scaled from its top-left.\nfunction bounds(s: number, g: Geo) {\n  const w = g.fw * s;\n  const h = g.fh * s;\n  const [minX, maxX] = w <= g.cw ? [(g.fw - w) / 2, (g.fw - w) / 2] : [g.cw - g.ox - w, -g.ox];\n  const [minY, maxY] = h <= g.ch ? [(g.fh - h) / 2, (g.fh - h) / 2] : [g.ch - g.oy - h, -g.oy];\n  return { minX, maxX, minY, maxY };\n}\n\nexport type UseZoomPanOptions = {\n  min?: number;\n  max?: number;\n  /** Multiplier per zoom-in or zoom-out step (buttons and keys). */\n  step?: number;\n  /** Scale a double-click or double-tap zooms to, around the point pressed. */\n  doubleTapScale?: number;\n  /** \"modifier\": the wheel scrolls the page and zooms only with ⌘/Ctrl or a trackpad pinch. \"always\": the wheel always zooms. */\n  wheel?: \"modifier\" | \"always\";\n  disabled?: boolean;\n  /** Called when a two-finger pinch starts, so a parent can drop a swipe it had begun. */\n  onPinchStart?: () => void;\n  onScaleChange?: (scale: number) => void;\n};\n\n/**\n * Zoom and pan for any content. Put `viewportRef` on a positioned element that clips, and\n * `contentRef` plus `style={{ x, y, scale, originX: 0, originY: 0 }}` on a motion element inside it.\n * At rest a single-pointer drag is left alone, so the page (or a parent carousel) can use it.\n */\nexport function useZoomPan({\n  min = 1,\n  max = 4,\n  step = 1.6,\n  doubleTapScale = 2.5,\n  wheel = \"modifier\",\n  disabled = false,\n  onPinchStart,\n  onScaleChange,\n}: UseZoomPanOptions = {}) {\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const scale = useMotionValue(min);\n  // Last measured layout, for anything drawn from the transform (a minimap, a readout).\n  const geometry = useMotionValue<Geo | null>(null);\n  const reduce = !!useReducedMotion();\n  // The scale the UI should show: the target of an animation, or the live value mid-gesture.\n  const [zoom, setZoom] = useState(min);\n  const [dragging, setDragging] = useState(false);\n  const [interacting, setInteracting] = useState(false);\n  const [hint, setHint] = useState<string | null>(null);\n  const running = useRef<AnimationPlaybackControls[]>([]);\n  const opts = useRef({ min, max, step, doubleTapScale, wheel, disabled, reduce, onPinchStart, onScaleChange });\n  useEffect(() => {\n    opts.current = { min, max, step, doubleTapScale, wheel, disabled, reduce, onPinchStart, onScaleChange };\n  });\n\n  const geo = useCallback((): Geo | null => {\n    const v = viewportRef.current;\n    const c = contentRef.current;\n    if (!v || !c) return null;\n    return { cw: v.clientWidth, ch: v.clientHeight, ox: c.offsetLeft, oy: c.offsetTop, fw: c.offsetWidth, fh: c.offsetHeight };\n  }, []);\n\n  const stop = useCallback(() => {\n    running.current.forEach((a) => a.stop());\n    running.current = [];\n  }, []);\n\n  const report = useCallback((s: number) => {\n    setZoom(s);\n    opts.current.onScaleChange?.(s);\n  }, []);\n\n  /** Zoom to `target`, keeping `anchor` (viewport px) still, then settle inside the bounds. */\n  const zoomTo = useCallback(\n    (target: number, anchor?: Point, { instant = false }: { instant?: boolean } = {}) => {\n      const g = geo();\n      if (!g) return;\n      stop();\n      const { min, max, reduce } = opts.current;\n      const s0 = scale.get();\n      const x0 = x.get();\n      const y0 = y.get();\n      const s1 = clamp(target, min, max);\n      const p = anchor ?? { x: g.cw / 2, y: g.ch / 2 };\n      const ax = (s: number) => p.x - g.ox - ((p.x - g.ox - x0) * s) / s0;\n      const ay = (s: number) => p.y - g.oy - ((p.y - g.oy - y0) * s) / s0;\n      const b = bounds(s1, g);\n      const x1 = clamp(ax(s1), b.minX, b.maxX);\n      const y1 = clamp(ay(s1), b.minY, b.maxY);\n      report(s1);\n      if (instant || reduce) {\n        scale.set(s1);\n        x.set(x1);\n        y.set(y1);\n        return;\n      }\n      // One progress value drives all three, so the point under the cursor stays put while the\n      // bounds correction blends in, instead of three springs drifting apart.\n      running.current.push(\n        animate(0, 1, {\n          ...spring.snappy,\n          onUpdate: (t) => {\n            const s = s0 + (s1 - s0) * t;\n            scale.set(s);\n            x.set(ax(s) + (x1 - ax(s1)) * t);\n            y.set(ay(s) + (y1 - ay(s1)) * t);\n          },\n        }),\n      );\n    },\n    [geo, stop, report, scale, x, y],\n  );\n\n  const reset = useCallback((o?: { instant?: boolean }) => zoomTo(opts.current.min, undefined, o), [zoomTo]);\n  const zoomIn = useCallback((o?: { instant?: boolean }) => zoomTo(zoom * opts.current.step, undefined, o), [zoomTo, zoom]);\n  const zoomOut = useCallback((o?: { instant?: boolean }) => zoomTo(zoom / opts.current.step, undefined, o), [zoomTo, zoom]);\n  const toggle = useCallback(\n    (at?: Point) => (scale.get() > opts.current.min + 0.01 ? zoomTo(opts.current.min) : zoomTo(opts.current.doubleTapScale, at)),\n    [zoomTo, scale],\n  );\n\n  // After a drag or pinch: back inside the scale limits, then coast to a stop inside the bounds.\n  const settle = useCallback(\n    (anchor: Point) => {\n      const g = geo();\n      if (!g) return;\n      const { min, max, reduce } = opts.current;\n      const s = scale.get();\n      if (s < min || s > max) return zoomTo(clamp(s, min, max), anchor);\n      const b = bounds(s, g);\n      report(s);\n      if (reduce) {\n        x.set(clamp(x.get(), b.minX, b.maxX));\n        y.set(clamp(y.get(), b.minY, b.maxY));\n        return;\n      }\n      const coast = (mv: typeof x, lo: number, hi: number) => {\n        const v = mv.getVelocity();\n        // The target only has to differ from the start, or the animation is skipped as a no-op;\n        // inertia works out the real resting place from the velocity and the bounds.\n        return animate(mv, clamp(mv.get() + v * 0.35, lo, hi), {\n          type: \"inertia\",\n          velocity: v,\n          min: lo,\n          max: hi,\n          power: 0.35,\n          timeConstant: 260,\n          bounceStiffness: 420,\n          bounceDamping: 42,\n          restDelta: 0.5,\n        });\n      };\n      running.current.push(coast(x, b.minX, b.maxX), coast(y, b.minY, b.maxY));\n    },\n    [geo, zoomTo, report, scale, x, y],\n  );\n\n  useEffect(() => {\n    const el = viewportRef.current;\n    if (!el) return;\n    const pointers = new Map<number, Point>();\n    let pan: { x: number; y: number; p: Point } | null = null;\n    let pinch: { s: number; x: number; y: number; mid: Point; dist: number } | null = null;\n    let traveled = 0;\n    let lastTap = { t: 0, x: 0, y: 0 };\n    let lastType = \"mouse\";\n    let wheelTimer = 0;\n    let hintTimer = 0;\n\n    const local = (e: PointerEvent | WheelEvent | MouseEvent): Point => {\n      const r = el.getBoundingClientRect();\n      return { x: e.clientX - r.left, y: e.clientY - r.top };\n    };\n    const two = () => {\n      const [a, b] = [...pointers.values()];\n      return { mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, dist: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)) };\n    };\n    const startPan = (p: Point) => {\n      pan = { x: x.get(), y: y.get(), p };\n      setDragging(true);\n    };\n    const startPinch = () => {\n      const { mid, dist } = two();\n      pinch = { s: scale.get(), x: x.get(), y: y.get(), mid, dist };\n      pan = null;\n      setInteracting(true);\n      opts.current.onPinchStart?.();\n    };\n\n    const down = (e: PointerEvent) => {\n      if (opts.current.disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n      // Controls laid over the image keep their own clicks.\n      if ((e.target as Element).closest(\"button, a, input, select, textarea, [data-zoom-ignore]\")) return;\n      lastType = e.pointerType;\n      pointers.set(e.pointerId, local(e));\n      traveled = 0;\n      if (pointers.size === 2) {\n        stop();\n        el.setPointerCapture(e.pointerId);\n        e.stopPropagation();\n        startPinch();\n      } else if (pointers.size === 1 && scale.get() > opts.current.min + 0.001) {\n        // Zoomed: this drag is ours. At rest it is left to the page or a parent.\n        stop();\n        el.setPointerCapture(e.pointerId);\n        e.stopPropagation();\n        startPan(local(e));\n      }\n    };\n\n    const move = (e: PointerEvent) => {\n      if (!pointers.has(e.pointerId)) return;\n      const p = local(e);\n      const prev = pointers.get(e.pointerId)!;\n      traveled += Math.hypot(p.x - prev.x, p.y - prev.y);\n      pointers.set(e.pointerId, p);\n      const g = geo();\n      if (!g) return;\n      if (pinch && pointers.size >= 2) {\n        const { min, max } = opts.current;\n        const { mid, dist } = two();\n        let s = (pinch.s * dist) / pinch.dist;\n        // Past the limits the pinch still gives, a little, then springs back on release.\n        if (s > max) s = max * Math.pow(s / max, 0.3);\n        if (s < min) s = min * Math.pow(s / min, 0.3);\n        const b = bounds(s, g);\n        const nx = mid.x - g.ox - ((pinch.mid.x - g.ox - pinch.x) * s) / pinch.s;\n        const ny = mid.y - g.oy - ((pinch.mid.y - g.oy - pinch.y) * s) / pinch.s;\n        scale.set(s);\n        x.set(rubber(nx, b.minX, b.maxX, g.cw));\n        y.set(rubber(ny, b.minY, b.maxY, g.ch));\n        setZoom(s);\n      } else if (pan) {\n        const b = bounds(scale.get(), g);\n        x.set(rubber(pan.x + p.x - pan.p.x, b.minX, b.maxX, g.cw));\n        y.set(rubber(pan.y + p.y - pan.p.y, b.minY, b.maxY, g.ch));\n      }\n    };\n\n    const up = (e: PointerEvent) => {\n      if (!pointers.has(e.pointerId)) return;\n      const p = pointers.get(e.pointerId)!;\n      pointers.delete(e.pointerId);\n      if (pinch) {\n        if (pointers.size === 1) {\n          // One finger lifted: carry on as a pan with the other, no jump.\n          pinch = null;\n          const [rest] = [...pointers.values()];\n          if (scale.get() > opts.current.min + 0.001) startPan(rest);\n          settleIfIdle(rest);\n        } else if (pointers.size === 0) {\n          pinch = null;\n          setInteracting(false);\n          settle(p);\n        }\n        return;\n      }\n      if (pan && pointers.size === 0) {\n        pan = null;\n        setDragging(false);\n        settle(p);\n      }\n      // Double-tap on touch; mice get the native dblclick.\n      if (e.type === \"pointerup\" && e.pointerType !== \"mouse\" && traveled < 10) {\n        const now = performance.now();\n        if (now - lastTap.t < 300 && Math.hypot(p.x - lastTap.x, p.y - lastTap.y) < 30) {\n          lastTap = { t: 0, x: 0, y: 0 };\n          toggle(p);\n        } else lastTap = { t: now, ...p };\n      }\n    };\n    // A pinch that drops to one finger while at rest has nothing left to pan: settle straight away.\n    const settleIfIdle = (p: Point) => {\n      if (!pan) {\n        setInteracting(false);\n        settle(p);\n      }\n    };\n\n    const dbl = (e: MouseEvent) => {\n      if (opts.current.disabled || lastType !== \"mouse\") return;\n      e.preventDefault();\n      toggle(local(e));\n    };\n\n    const onWheel = (e: WheelEvent) => {\n      const o = opts.current;\n      if (o.disabled) return;\n      const g = geo();\n      if (!g) return;\n      const zooming = e.ctrlKey || e.metaKey || o.wheel === \"always\";\n      const s = scale.get();\n      if (zooming) {\n        e.preventDefault();\n        stop();\n        const d = clamp(e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY, -25, 25);\n        const s1 = clamp(s * Math.exp(-d * 0.01), o.min, o.max);\n        const p = local(e);\n        const b = bounds(s1, g);\n        scale.set(s1);\n        x.set(clamp(p.x - g.ox - ((p.x - g.ox - x.get()) * s1) / s, b.minX, b.maxX));\n        y.set(clamp(p.y - g.oy - ((p.y - g.oy - y.get()) * s1) / s, b.minY, b.maxY));\n        report(s1);\n        setInteracting(true);\n        window.clearTimeout(wheelTimer);\n        wheelTimer = window.setTimeout(() => setInteracting(false), 180);\n      } else if (s > o.min + 0.001) {\n        e.preventDefault();\n        stop();\n        const b = bounds(s, g);\n        x.set(clamp(x.get() - e.deltaX, b.minX, b.maxX));\n        y.set(clamp(y.get() - e.deltaY, b.minY, b.maxY));\n      } else if (Math.abs(e.deltaY) > 2) {\n        // The page scrolls as normal; say once how to zoom instead of stealing the wheel.\n        setHint(/Mac|iPhone|iPad/.test(navigator.userAgent) ? \"⌘\" : \"Ctrl\");\n        window.clearTimeout(hintTimer);\n        hintTimer = window.setTimeout(() => setHint(null), 1500);\n      }\n    };\n\n    el.addEventListener(\"pointerdown\", down);\n    el.addEventListener(\"pointermove\", move);\n    el.addEventListener(\"pointerup\", up);\n    el.addEventListener(\"pointercancel\", up);\n    el.addEventListener(\"dblclick\", dbl);\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n\n    // Keep the content inside its bounds when the viewport or the fitted image changes size.\n    const ro = new ResizeObserver(() => {\n      const g = geo();\n      if (!g) return;\n      geometry.set(g);\n      const b = bounds(scale.get(), g);\n      x.set(clamp(x.get(), b.minX, b.maxX));\n      y.set(clamp(y.get(), b.minY, b.maxY));\n    });\n    ro.observe(el);\n    if (contentRef.current) ro.observe(contentRef.current);\n\n    return () => {\n      el.removeEventListener(\"pointerdown\", down);\n      el.removeEventListener(\"pointermove\", move);\n      el.removeEventListener(\"pointerup\", up);\n      el.removeEventListener(\"pointercancel\", up);\n      el.removeEventListener(\"dblclick\", dbl);\n      el.removeEventListener(\"wheel\", onWheel);\n      ro.disconnect();\n      window.clearTimeout(wheelTimer);\n      window.clearTimeout(hintTimer);\n      stop();\n    };\n  }, [geo, stop, settle, toggle, report, scale, x, y, geometry]);\n\n  /** + / − zoom, 0 fits, arrows pan while zoomed. Keyboard changes land instantly. */\n  const onKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      const o = opts.current;\n      if (o.disabled || e.metaKey || e.ctrlKey || e.altKey) return;\n      const g = geo();\n      if (!g) return;\n      const s = scale.get();\n      if (e.key === \"+\" || e.key === \"=\") zoomTo(s * o.step, undefined, { instant: true });\n      else if (e.key === \"-\" || e.key === \"_\") zoomTo(s / o.step, undefined, { instant: true });\n      else if (e.key === \"0\") zoomTo(o.min, undefined, { instant: true });\n      else if (e.key.startsWith(\"Arrow\") && s > o.min + 0.001) {\n        const d = e.shiftKey ? 160 : 48;\n        const b = bounds(s, g);\n        if (e.key === \"ArrowLeft\") x.set(clamp(x.get() + d, b.minX, b.maxX));\n        if (e.key === \"ArrowRight\") x.set(clamp(x.get() - d, b.minX, b.maxX));\n        if (e.key === \"ArrowUp\") y.set(clamp(y.get() + d, b.minY, b.maxY));\n        if (e.key === \"ArrowDown\") y.set(clamp(y.get() - d, b.minY, b.maxY));\n      } else return;\n      e.preventDefault();\n    },\n    [geo, zoomTo, scale, x, y],\n  );\n\n  return {\n    viewportRef,\n    contentRef,\n    x,\n    y,\n    scale,\n    geometry,\n    zoom,\n    zoomed: zoom > min + 0.001,\n    dragging,\n    interacting,\n    hint,\n    zoomTo,\n    zoomIn,\n    zoomOut,\n    reset,\n    toggle,\n    onKeyDown,\n  };\n}\n\nexport type ZoomPanImageProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  src: string;\n  alt: string;\n  /** Intrinsic size. Lets the image be fitted before it loads; otherwise it is read on load. */\n  width?: number;\n  height?: number;\n  /** Shape of the viewer. Pass null to size it with className instead. */\n  aspectRatio?: number | string | null;\n  min?: number;\n  max?: number;\n  step?: number;\n  doubleTapScale?: number;\n  wheel?: \"modifier\" | \"always\";\n  /** Zoom out, level and zoom in, bottom right. */\n  controls?: boolean;\n  /** An overview with the visible region, bottom left, while zoomed. */\n  minimap?: boolean;\n  onScaleChange?: (scale: number) => void;\n};\n\nexport function ZoomPanImage({\n  src,\n  alt,\n  width,\n  height,\n  aspectRatio = \"4 / 3\",\n  min = 1,\n  max = 4,\n  step = 1.6,\n  doubleTapScale = 2.5,\n  wheel = \"modifier\",\n  controls = true,\n  minimap = true,\n  onScaleChange,\n  className,\n  style,\n  onKeyDown,\n  ...rest\n}: ZoomPanImageProps) {\n  const {\n    viewportRef,\n    contentRef,\n    x,\n    y,\n    scale,\n    geometry,\n    zoom,\n    zoomed,\n    dragging,\n    interacting,\n    hint,\n    zoomIn,\n    zoomOut,\n    reset,\n    onKeyDown: keys,\n  } = useZoomPan({ min, max, step, doubleTapScale, wheel, onScaleChange });\n  const reduce = !!useReducedMotion();\n  const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);\n  const [failed, setFailed] = useState(false);\n  const helpId = useId();\n  const ratio = width && height ? width / height : natural ? natural.w / natural.h : null;\n  const pct = Math.round(zoom * 100);\n\n  // The visible region, drawn on the minimap as fractions of the image.\n  const region = useTransform(() => {\n    const g = geometry.get();\n    const s = scale.get();\n    const tx = x.get();\n    const ty = y.get();\n    if (!g || !g.fw || !g.fh) return { l: 0, t: 0, w: 1, h: 1 };\n    const l = clamp((-g.ox - tx) / s / g.fw, 0, 1);\n    const t = clamp((-g.oy - ty) / s / g.fh, 0, 1);\n    const r = clamp((g.cw - g.ox - tx) / s / g.fw, 0, 1);\n    const b = clamp((g.ch - g.oy - ty) / s / g.fh, 0, 1);\n    return { l, t, w: r - l, h: b - t };\n  });\n  const left = useTransform(() => `${region.get().l * 100}%`);\n  const top = useTransform(() => `${region.get().t * 100}%`);\n  const regionW = useTransform(() => `${region.get().w * 100}%`);\n  const regionH = useTransform(() => `${region.get().h * 100}%`);\n\n  const loaded = (img: HTMLImageElement) => {\n    const w = img.naturalWidth;\n    const h = img.naturalHeight;\n    if (w) setNatural((n) => (n && n.w === w && n.h === h ? n : { w, h }));\n  };\n\n  return (\n    <div\n      {...rest}\n      ref={viewportRef}\n      tabIndex={0}\n      role=\"group\"\n      aria-roledescription=\"zoomable image\"\n      aria-label={alt}\n      aria-describedby={helpId}\n      data-zoomed={zoomed || undefined}\n      data-dragging={dragging || undefined}\n      style={{ aspectRatio: aspectRatio ?? undefined, ...style }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (!e.defaultPrevented) keys(e);\n      }}\n      className={cn(\n        \"group/zp relative isolate select-none overflow-hidden rounded-xl border border-line bg-frame [container-type:size]\",\n        // At rest one finger scrolls the page and two pinch the image; zoomed, every touch is ours.\n        \"touch-pan-x touch-pan-y data-zoomed:touch-none\",\n        \"cursor-zoom-in data-zoomed:cursor-grab data-dragging:cursor-grabbing\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        className,\n      )}\n    >\n      <motion.div\n        ref={contentRef}\n        className=\"absolute inset-0 m-auto will-change-transform\"\n        style={{\n          x: x,\n          y: y,\n          scale: scale,\n          originX: 0,\n          originY: 0,\n          // Contain-fit without measuring: the box is as large as the viewport allows at the image's ratio.\n          width: ratio ? `min(100cqw, 100cqh * ${ratio})` : \"100%\",\n          height: ratio ? `min(100cqh, 100cqw / ${ratio})` : \"100%\",\n        }}\n      >\n        {/* eslint-disable-next-line @next/next/no-img-element */}\n        <img\n          ref={(img) => {\n            if (img?.complete) loaded(img);\n          }}\n          src={src}\n          alt=\"\"\n          draggable={false}\n          decoding=\"async\"\n          onLoad={(e) => loaded(e.currentTarget)}\n          onError={() => setFailed(true)}\n          className={cn(\n            \"pointer-events-none size-full object-contain transition-opacity duration-300 ease-out\",\n            natural || (width && height) ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </motion.div>\n\n      {failed && (\n        <p className=\"absolute inset-0 grid place-items-center p-4 text-center text-[12.5px] text-fg-3\">Couldn’t load this image</p>\n      )}\n\n      <p id={helpId} className=\"sr-only\">\n        Use plus and minus to zoom, the arrow keys to move around while zoomed, and 0 to fit.\n      </p>\n      <p role=\"status\" className=\"sr-only\">\n        {interacting ? \"\" : `Zoom ${pct}%`}\n      </p>\n\n      {/* Scrolling over the image scrolls the page. The first time, say how to zoom instead. */}\n      <div\n        aria-hidden\n        data-show={hint ? \"\" : undefined}\n        className={cn(\n          \"pointer-events-none absolute inset-x-0 top-3 flex justify-center\",\n          \"opacity-0 transition-[opacity,translate] duration-200 ease-out-expo -translate-y-1 data-show:translate-y-0 data-show:opacity-100 motion-reduce:translate-y-0\",\n        )}\n      >\n        <span className=\"rounded-full border border-line-2 bg-raised/90 px-2.5 py-1 text-[12px] text-fg-2 shadow-pop backdrop-blur-sm\">\n          Hold <kbd className=\"font-mono text-[11.5px] text-fg\">{hint ?? \"⌘\"}</kbd> and scroll to zoom\n        </span>\n      </div>\n\n      {minimap && ratio && (\n        <div\n          aria-hidden\n          data-show={zoomed ? \"\" : undefined}\n          className={cn(\n            \"pointer-events-none absolute bottom-2.5 left-2.5 hidden w-20 overflow-hidden rounded-md @min-[420px]:block border border-line-2 bg-raised shadow-pop\",\n            \"origin-bottom-left scale-95 opacity-0 transition-[opacity,scale] duration-200 ease-out-expo data-show:scale-100 data-show:opacity-100 motion-reduce:scale-100\",\n          )}\n          style={{ aspectRatio: ratio }}\n        >\n          {/* eslint-disable-next-line @next/next/no-img-element */}\n          <img src={src} alt=\"\" draggable={false} className=\"size-full object-cover\" />\n          <motion.span\n            className=\"absolute rounded-[2px] border border-fg shadow-[0_0_0_120px_var(--overlay)]\"\n            style={{ left, top, width: regionW, height: regionH }}\n          />\n        </div>\n      )}\n\n      {controls && (\n        <div\n          // Presses on the controls never start a pan or a double-click zoom underneath.\n          onPointerDown={(e) => e.stopPropagation()}\n          onDoubleClick={(e) => e.stopPropagation()}\n          className=\"absolute bottom-2.5 right-2.5 flex cursor-default items-center rounded-lg border border-line-2 bg-raised/90 p-0.5 shadow-pop backdrop-blur-sm\"\n        >\n          <ControlButton label=\"Zoom out\" disabled={zoom <= min + 0.001} onClick={() => zoomOut()}>\n            <Minus size={14} />\n          </ControlButton>\n          <button\n            type=\"button\"\n            onClick={() => reset()}\n            disabled={!zoomed}\n            aria-label=\"Fit to view\"\n            title=\"Fit to view (0)\"\n            className={cn(\n              \"h-7 w-12 rounded-md font-mono text-[11.5px] text-fg-2 tabular\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n              \"transition-[background-color,color,scale] duration-150 enabled:hover:bg-hover enabled:hover:text-fg enabled:active:scale-[0.95] enabled:active:duration-75\",\n            )}\n          >\n            <NumberFlow value={pct} suffix=\"%\" animated={!interacting && !reduce} />\n          </button>\n          <ControlButton label=\"Zoom in\" disabled={zoom >= max - 0.001} onClick={() => zoomIn()}>\n            <Plus size={14} />\n          </ControlButton>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction ControlButton({ label, className, children, ...rest }: React.ComponentProps<\"button\"> & { label: string }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      className={cn(\n        \"relative grid size-7 place-items-center rounded-md text-fg-2\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n        \"transition-[background-color,color,scale,opacity] duration-150 enabled:hover:bg-hover enabled:hover:text-fg enabled:active:scale-[0.92] enabled:active:duration-75\",\n        \"disabled:opacity-40\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/zoom-pan-image"
}
