{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-zoom",
  "title": "Image Zoom",
  "description": "A frame that zooms whatever is inside it — wheel, trackpad pinch, double click or the buttons — and lets you drag it around once it no longer fits. Reach for it wherever a picture is too detailed to read at the size it is shown: product and listing photography where the buyer wants the fabric, the stitching or the serial number, floor plans and architectural drawings, engineering and CAD schematics, circuit diagrams, maps and site plans shipped as images, satellite and aerial imagery, medical scans and microscopy, screenshots in documentation and bug reports, design and artwork review, scanned documents and receipts, org charts and flow diagrams, dense data visualisations, archive and museum photography, and any figure in an article that is legible on a desktop and unreadable on a phone. Common asks it answers: \"image zoom react\", \"pan and zoom component\", \"pinch to zoom image\", \"zoom into image on scroll\", \"wheel zoom image react\", \"draggable zoomable image\", \"zoom and drag image\", \"react-zoom-pan-pinch alternative\", \"medium-zoom alternative\", \"panzoom alternative\", \"image viewer component\", \"zoomable diagram\", \"magnify image component\", \"shadcn image zoom\", \"shadcn pan zoom\". Official shadcn/ui has nothing for this and no combination of its parts reaches it: aspect-ratio only holds a box at a shape, carousel moves between pictures rather than into one, dialog can present an image larger but cannot magnify part of it, and scroll-area scrolls content it never scales. Distinct from image-comparison, which slides between two pictures of the same thing, and from image-crop, which chooses a region to save — this one changes nothing and only alters how closely you are looking. Pairs with fullscreen-button, which is how the frame gets big enough for zooming to pay off: go full screen first, then zoom in. The component turns on one piece of arithmetic: whatever is under the pointer stays under the pointer. Applying scale() about the centre of the frame is the one-line version everybody writes, and it is useless on anything worth zooming — the detail you aimed at slides toward the edge as you go in, faster the further from the middle it started, so reading a label becomes a game of zoom-then-drag-it-back. Here the translation is re-solved for each new scale so the anchor holds, and the scale is clamped before the translation is solved rather than after, which is what stops the picture lurching sideways on every wheel notch past the maximum. Four more things it settles that are invisible until they are wrong. The wheel listener is registered by hand with passive: false, because React attaches onWheel passively in several browsers and a passive listener's preventDefault is silently ignored — the frame zooms while the page scrolls out from under it. A trackpad pinch arrives as a wheel event with ctrlKey set rather than as a touch gesture, so a component written only for touch does nothing at all on a laptop; both paths run through one handler here. Wheel deltas are read with deltaMode, because Chrome and Safari report pixels while Firefox reports lines — ignoring it makes the zoom feel right in one browser and roughly sixteen times too slow in the other. And the steps are exponential rather than linear, so scrolling in and back out lands on the scale you started from instead of creeping smaller all afternoon. Everything the pointer can do has a keyboard form, because none of these gestures have one on their own: the frame is a tab stop, + and - zoom about its centre, the arrow keys pan, and 0 resets. The arrows are left to the page when there is nothing to pan, and touch-action is pan-y while the content fits and none once it does not, so a full-width frame is never something you cannot scroll past on a phone. The controls are labelled buttons marked aria-disabled rather than disabled — a real disabled drops them out of the tab order the moment you reach a limit — and they stay silent instead of reporting a change that did not happen. The zoom level is shown as a percentage in a polite live region, so it is announced rather than left as a visual-only state. Pan is clamped against the content's own unzoomed size rather than the frame's, so a letterboxed picture cannot be dragged half out of the box, and the frame re-clamps when it is resized, which is what a phone rotated while zoomed in needs. Uncontrolled by default (defaultTransform) or controlled by passing transform with onTransformChange. The maths is exported too — zoomAt, clampPan, clampScale and contentPointAt — so an annotation layer can map a click back to a point on the unzoomed image without reimplementing any of it. Styled entirely with shadcn tokens (border, background, muted, ring, accent), so it follows light and dark mode, and it ships zero dependencies — no Radix, no icon package, one file.",
  "files": [
    {
      "path": "registry/ui/image-zoom.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** A point in the frame's own coordinates: CSS pixels from the inside of its top-left corner. */\nexport interface ZoomPoint {\n  x: number\n  y: number\n}\n\n/** A box in CSS pixels. */\nexport interface ZoomSize {\n  width: number\n  height: number\n}\n\n/**\n * Where the content sits, as `translate(x, y) scale(scale)` with the origin pinned to the frame's\n * top-left: a point `p` of the unzoomed content is drawn at `p * scale + (x, y)`.\n *\n * The origin is the corner rather than the centre on purpose. With a centred origin the same\n * translation means a different thing at every zoom level, and every calculation below would have\n * to carry the frame's size around to say where anything is.\n */\nexport interface ZoomTransform {\n  scale: number\n  x: number\n  y: number\n}\n\nexport const DEFAULT_MIN_SCALE = 1\nexport const DEFAULT_MAX_SCALE = 8\n\n/** Unzoomed, unpanned: the content exactly covering the frame. */\nexport const IDENTITY_TRANSFORM: ZoomTransform = { scale: 1, x: 0, y: 0 }\n\n/** What one press of a zoom button, or of `+` / `-`, multiplies the scale by. */\nconst DEFAULT_ZOOM_STEP = 1.5\n/** How far one arrow key slides the content, in CSS pixels. */\nconst DEFAULT_PAN_STEP = 40\n/** What a double click zooms to when the content is sitting at its minimum. */\nconst DOUBLE_CLICK_STEP = 2.5\n\n/**\n * Wheel deltas do not arrive in one unit. Chrome and Safari report pixels, Firefox reports lines,\n * and a wheel bound to page scrolling reports pages. Reading `deltaY` without `deltaMode` is the\n * bug that makes a zoom feel right in Chrome and roughly sixteen times too slow in Firefox.\n */\nconst PIXELS_PER_LINE = 16\nconst PIXELS_PER_PAGE = 400\n\n/**\n * No single event may move more than this many pixels' worth of zoom. Some devices and some\n * browsers emit one enormous delta for a flick, which without a cap jumps from 1x to the maximum\n * in a single notch and looks like a glitch rather than a zoom.\n */\nconst MAX_WHEEL_PIXELS = 120\n\n/** Chosen so one ordinary mouse notch (about 100px) is a ~1.4x step. */\nconst WHEEL_SPEED = 0.0035\n\n/** Scales closer than this are the same scale, for deciding whether anything actually moved. */\nconst EPSILON = 1e-6\n\n/**\n * The wheel delta in pixels, whatever unit the browser reported it in, capped both ways.\n *\n * Non-finite deltas come back as zero rather than propagating: a NaN here would reach `Math.exp`,\n * come back NaN, and settle into the transform as a scale that no later clamp can recover from.\n */\nexport function normalizeWheelDelta(deltaY: number, deltaMode: number = 0): number {\n  if (!Number.isFinite(deltaY)) return 0\n  const unit = deltaMode === 1 ? PIXELS_PER_LINE : deltaMode === 2 ? PIXELS_PER_PAGE : 1\n  const pixels = deltaY * unit\n  if (pixels > MAX_WHEEL_PIXELS) return MAX_WHEEL_PIXELS\n  if (pixels < -MAX_WHEEL_PIXELS) return -MAX_WHEEL_PIXELS\n  return pixels\n}\n\n/**\n * What to multiply the scale by for one wheel event.\n *\n * Exponential rather than linear, because zoom is multiplicative: `exp(-d)` and `exp(d)` are\n * reciprocals, so rolling the wheel back the same distance lands on the scale you started from.\n * The obvious `1 - delta * k` does not have that property — scroll in and back out and the picture\n * ends up slightly smaller every time, which over a minute of fiddling is very noticeable and\n * impossible to attribute to anything.\n */\nexport function wheelScaleFactor(deltaY: number, deltaMode: number = 0): number {\n  return Math.exp(-normalizeWheelDelta(deltaY, deltaMode) * WHEEL_SPEED)\n}\n\nexport function clampScale(\n  scale: number,\n  minScale: number = DEFAULT_MIN_SCALE,\n  maxScale: number = DEFAULT_MAX_SCALE\n): number {\n  // An infinity is a real request — \"as far in as it will go\" — and the comparisons below land it\n  // on the limit it was heading for. NaN is not: it fails both comparisons and would reach the\n  // style as \"scale(NaN)\", which browsers drop, leaving a frame that has quietly stopped\n  // responding to the wheel. The coercion is what catches a value that is not a number at all,\n  // since Number.isNaN alone answers false for undefined and lets it through to the same place.\n  if (Number.isNaN(Number(scale))) return minScale\n  return scale < minScale ? minScale : scale > maxScale ? maxScale : scale\n}\n\n/** Which point of the unzoomed content is drawn under `point`. The inverse of the transform. */\nexport function contentPointAt(transform: ZoomTransform, point: ZoomPoint): ZoomPoint {\n  if (!Number.isFinite(transform.scale) || transform.scale <= 0) return { x: 0, y: 0 }\n  return {\n    x: (point.x - transform.x) / transform.scale,\n    y: (point.y - transform.y) / transform.scale,\n  }\n}\n\n/**\n * Zoom to `requestedScale` while leaving whatever is under `point` exactly where it is.\n *\n * This is the one piece of arithmetic in the component, and the thing every naive zoom gets wrong.\n * Applying `scale()` about the centre of the frame is a one-line change that looks correct on a\n * photo of the sky and is useless on anything you would actually want to zoom: the detail you\n * pointed at slides toward the edge as you go in, faster the further from the middle it started,\n * so reading a label on a diagram becomes a game of zoom-then-drag-it-back.\n *\n * Keeping it still is just solving for the new translation. The content point under the cursor is\n * `p = (point - t) / s`; it stays under the cursor when `p * s' + t' = point`, so\n * `t' = point - (point - t) * s'/s`.\n *\n * The scale is clamped *before* the translation is solved. That ordering is why this is one\n * function and not a `clampScale` the caller remembers to apply afterwards: clamp second and every\n * wheel notch past the maximum still shifts the picture sideways, because the translation answers\n * a zoom level the content never reached. Here it cannot be got wrong from outside.\n */\nexport function zoomAt(\n  transform: ZoomTransform,\n  point: ZoomPoint,\n  requestedScale: number,\n  minScale: number = DEFAULT_MIN_SCALE,\n  maxScale: number = DEFAULT_MAX_SCALE\n): ZoomTransform {\n  const scale = clampScale(requestedScale, minScale, maxScale)\n  if (!Number.isFinite(transform.scale) || transform.scale <= 0) return { scale, x: 0, y: 0 }\n  const ratio = scale / transform.scale\n  return {\n    scale,\n    x: point.x - (point.x - transform.x) * ratio,\n    y: point.y - (point.y - transform.y) * ratio,\n  }\n}\n\n/**\n * One axis of the pan limit: the content may not be dragged away from the frame it fills.\n *\n * `extent` is the frame, `natural` the content's unzoomed length along the same axis. Drawn\n * smaller than the frame it is centred rather than clamped, because there is no inside to slide\n * to — and leaving it where the arithmetic put it lets a zoomed-out picture sit in a corner with\n * a wedge of background beside it.\n */\nfunction clampAxis(offset: number, extent: number, natural: number, scale: number): number {\n  const drawn = natural * scale\n  const centred = (extent - drawn) / 2\n  if (!Number.isFinite(drawn) || !Number.isFinite(offset)) return Number.isFinite(centred) ? centred : 0\n  if (drawn <= extent) return centred\n  const min = extent - drawn\n  return offset > 0 ? 0 : offset < min ? min : offset\n}\n\n/**\n * Keep the content covering the frame.\n *\n * `content` is the content's size *before* zoom. It is passed separately rather than assumed equal\n * to the frame, because a caller who gives the frame its own height gets letterboxing, and reading\n * the limit off the frame in that case lets the picture be dragged until it is half off screen.\n */\nexport function clampPan(\n  transform: ZoomTransform,\n  viewport: ZoomSize,\n  content: ZoomSize = viewport\n): ZoomTransform {\n  return {\n    scale: transform.scale,\n    x: clampAxis(transform.x, viewport.width, content.width, transform.scale),\n    y: clampAxis(transform.y, viewport.height, content.height, transform.scale),\n  }\n}\n\nfunction sameTransform(a: ZoomTransform, b: ZoomTransform): boolean {\n  return (\n    Math.abs(a.scale - b.scale) < EPSILON &&\n    Math.abs(a.x - b.x) < EPSILON &&\n    Math.abs(a.y - b.y) < EPSILON\n  )\n}\n\nfunction distance(a: ZoomPoint, b: ZoomPoint): number {\n  return Math.hypot(a.x - b.x, a.y - b.y)\n}\n\nfunction midpoint(a: ZoomPoint, b: ZoomPoint): ZoomPoint {\n  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }\n}\n\nexport interface ImageZoomProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n  /**\n   * What to zoom — usually an `<img>`, but any single box works (a `next/image`, an `<svg>` chart,\n   * a map tile, a `<canvas>`). It is laid out in normal flow at full width, so it is what gives the\n   * frame its height and there is no aspect ratio to declare. Transforms do not affect layout, so\n   * the frame keeps that height however far in you zoom.\n   */\n  children: React.ReactNode\n  /** How far out the content can go, as a multiple of its fitted size (default 1 — no zooming out). */\n  minScale?: number\n  /** How far in the content can go (default 8). */\n  maxScale?: number\n  /** What one button press, or one `+` / `-`, multiplies the scale by (default 1.5). */\n  zoomStep?: number\n  /** How far one arrow key slides the content, in pixels (default 40). */\n  panStep?: number\n  /** Where the content starts. Ignored once `transform` is passed. */\n  defaultTransform?: ZoomTransform\n  /** The transform for a controlled component. Clamped on the way in. */\n  transform?: ZoomTransform\n  /** Called with the clamped transform whenever it changes. */\n  onTransformChange?: (transform: ZoomTransform) => void\n  /** Show the zoom in / out / reset buttons and the percentage (default true). */\n  showControls?: boolean\n  /** Classes for the controls cluster, e.g. \"bottom-2 top-auto\". */\n  controlsClassName?: string\n  /** Accessible name for the frame, e.g. \"Floor plan, scroll to zoom\". */\n  \"aria-label\"?: string\n}\n\nconst ICON = {\n  in: \"M12 9v6M9 12h6\",\n  out: \"M9 12h6\",\n  reset: \"M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3\",\n}\n\n/**\n * A frame that zooms what is inside it with the wheel, a trackpad pinch or a double click, and\n * lets you drag it around once it no longer fits.\n *\n * ```tsx\n * <ImageZoom aria-label=\"Site plan, scroll to zoom\">\n *   <img src=\"/site-plan.png\" alt=\"Site plan of the north building\" />\n * </ImageZoom>\n * ```\n *\n * Everything the pointer can do has a keyboard equivalent, because none of the gestures have one\n * on their own: the frame is a tab stop, `+` and `-` zoom about its centre, the arrow keys pan,\n * and `0` resets. The buttons do the same and are the reliable way in on a phone, where the first\n * pinch may still belong to the browser (see the note on `touch-action` below).\n */\nexport const ImageZoom = React.forwardRef<HTMLDivElement, ImageZoomProps>(function ImageZoom(\n  {\n    className,\n    style,\n    children,\n    minScale = DEFAULT_MIN_SCALE,\n    maxScale = DEFAULT_MAX_SCALE,\n    zoomStep = DEFAULT_ZOOM_STEP,\n    panStep = DEFAULT_PAN_STEP,\n    defaultTransform,\n    transform: transformProp,\n    onTransformChange,\n    showControls = true,\n    controlsClassName,\n    \"aria-label\": ariaLabel = \"Zoomable image\",\n    ...props\n  },\n  ref\n) {\n  const frameRef = React.useRef<HTMLDivElement | null>(null)\n  const contentRef = React.useRef<HTMLDivElement | null>(null)\n  React.useImperativeHandle(ref, () => frameRef.current as HTMLDivElement, [])\n\n  const resting = React.useMemo<ZoomTransform>(\n    () => ({ scale: clampScale(minScale, minScale, maxScale), x: 0, y: 0 }),\n    [minScale, maxScale]\n  )\n\n  const [uncontrolled, setUncontrolled] = React.useState<ZoomTransform>(\n    () => defaultTransform ?? resting\n  )\n  const isControlled = transformProp !== undefined\n  const raw = isControlled ? transformProp : uncontrolled\n  const transform: ZoomTransform = {\n    scale: clampScale(raw.scale, minScale, maxScale),\n    x: Number.isFinite(raw.x) ? raw.x : 0,\n    y: Number.isFinite(raw.y) ? raw.y : 0,\n  }\n\n  /**\n   * The frame and the unzoomed content, in CSS pixels.\n   *\n   * `clientWidth` rather than the bounding rect, because that is the box the content is laid out\n   * in — the rect includes the border, and measuring the limit a border too wide lets the picture\n   * be dragged a pixel past the edge at every zoom level. `offsetWidth` on the content for the\n   * same reason it is used at all: it is a layout value, so it reports the unzoomed size while a\n   * bounding rect would report the zoomed one and fold the answer back into its own question.\n   *\n   * Zero before the first layout, which is also the server's answer; every caller below treats a\n   * zero frame as \"nothing to clamp against yet\" rather than as a real limit.\n   */\n  const measure = React.useCallback(() => {\n    const frame = frameRef.current\n    const content = contentRef.current\n    return {\n      viewport: { width: frame?.clientWidth ?? 0, height: frame?.clientHeight ?? 0 },\n      content: { width: content?.offsetWidth ?? 0, height: content?.offsetHeight ?? 0 },\n    }\n  }, [])\n\n  const commit = React.useCallback(\n    (next: ZoomTransform) => {\n      const { viewport, content } = measure()\n      const value = clampPan(\n        { ...next, scale: clampScale(next.scale, minScale, maxScale) },\n        viewport,\n        content.width > 0 && content.height > 0 ? content : viewport\n      )\n      if (!isControlled) setUncontrolled(value)\n      onTransformChange?.(value)\n      return value\n    },\n    [isControlled, maxScale, measure, minScale, onTransformChange]\n  )\n\n  /** Where a pointer event landed, inside the frame's own box. */\n  const pointFrom = React.useCallback((event: { clientX: number; clientY: number }): ZoomPoint => {\n    const frame = frameRef.current\n    const box = frame?.getBoundingClientRect?.()\n    // clientX minus the box rather than offsetX: once a drag is captured the pointer keeps\n    // reporting after it leaves the frame, and offsetX is then measured against whatever it is\n    // over instead. clientLeft takes off the border, so the origin matches the content's.\n    return {\n      x: event.clientX - (box?.left ?? 0) - (frame?.clientLeft ?? 0),\n      y: event.clientY - (box?.top ?? 0) - (frame?.clientTop ?? 0),\n    }\n  }, [])\n\n  const centre = React.useCallback((): ZoomPoint => {\n    const { viewport } = measure()\n    return { x: viewport.width / 2, y: viewport.height / 2 }\n  }, [measure])\n\n  const zoomBy = React.useCallback(\n    (factor: number, at?: ZoomPoint) =>\n      commit(zoomAt(transform, at ?? centre(), transform.scale * factor, minScale, maxScale)),\n    [centre, commit, maxScale, minScale, transform]\n  )\n\n  const canZoomIn = transform.scale < maxScale - EPSILON\n  const canZoomOut = transform.scale > minScale + EPSILON\n  const isReset = sameTransform(transform, resting)\n\n  /**\n   * The wheel listener is attached by hand, with `passive: false`.\n   *\n   * React attaches its own `onWheel` passively in several browsers, and a passive listener's\n   * `preventDefault` is ignored with a console warning — so the frame zooms *and* the page scrolls\n   * out from under it. There is no prop that fixes that; the listener has to be registered here.\n   *\n   * It is registered once. The handler it calls is kept in a ref and replaced every render, so the\n   * listener always runs against the current transform without the frame re-subscribing on every\n   * notch of the wheel.\n   */\n  const wheelHandler = React.useRef<(event: WheelEvent) => void>(() => {})\n  React.useEffect(() => {\n    wheelHandler.current = (event: WheelEvent) => {\n      // A trackpad pinch arrives here as a wheel event with ctrlKey set — it is not a separate\n      // gesture, and a component that only listens for touch events pinches on a phone and does\n      // nothing at all on a laptop. Both paths want the same thing, so both take this one.\n      const next = zoomAt(\n        transform,\n        pointFrom(event),\n        transform.scale * wheelScaleFactor(event.deltaY, event.deltaMode),\n        minScale,\n        maxScale\n      )\n      // Claim the gesture only when it was used. At rest, a wheel that would zoom further out\n      // changes nothing, so the event is left alone and the page scrolls on past — which is what\n      // stops a full-width frame from being a hole you cannot scroll through on the way down.\n      if (sameTransform(next, transform)) return\n      event.preventDefault()\n      commit(next)\n    }\n  })\n  React.useEffect(() => {\n    const frame = frameRef.current\n    if (!frame?.addEventListener) return\n    const listener = (event: WheelEvent) => wheelHandler.current(event)\n    frame.addEventListener(\"wheel\", listener, { passive: false })\n    return () => frame.removeEventListener(\"wheel\", listener)\n  }, [])\n\n  /**\n   * Re-clamp when the frame changes size. Without this, a phone rotated while zoomed in keeps the\n   * offsets it worked out for the old width and the picture stays stuck off to one side — the\n   * limits are only ever recomputed on the next gesture, and there may not be one.\n   */\n  const reclamp = React.useRef<() => void>(() => {})\n  React.useEffect(() => {\n    reclamp.current = () => commit(transform)\n  })\n  React.useEffect(() => {\n    const frame = frameRef.current\n    if (!frame || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(() => reclamp.current())\n    observer.observe(frame)\n    return () => observer.disconnect()\n  }, [])\n\n  const pointers = React.useRef(new Map<number, ZoomPoint>())\n  const pinch = React.useRef<{ distance: number; centre: ZoomPoint } | null>(null)\n  const [dragging, setDragging] = React.useState(false)\n\n  const twoPointers = (): [ZoomPoint, ZoomPoint] | null => {\n    const points = [...pointers.current.values()]\n    return points.length >= 2 ? [points[0], points[1]] : null\n  }\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" && event.button !== 0) return\n    pointers.current.set(event.pointerId, pointFrom(event))\n    event.currentTarget.setPointerCapture?.(event.pointerId)\n    const pair = twoPointers()\n    if (pair) {\n      pinch.current = { distance: distance(pair[0], pair[1]), centre: midpoint(pair[0], pair[1]) }\n      setDragging(false)\n    } else if (canZoomOut) {\n      setDragging(true)\n    }\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.has(event.pointerId)) return\n    const previous = pointers.current.get(event.pointerId) as ZoomPoint\n    const current = pointFrom(event)\n    pointers.current.set(event.pointerId, current)\n\n    const pair = twoPointers()\n    if (pair && pinch.current) {\n      const spread = distance(pair[0], pair[1])\n      const anchor = midpoint(pair[0], pair[1])\n      if (spread <= 0 || pinch.current.distance <= 0) return\n      // Measured against the last frame rather than the start of the gesture, so the two fingers\n      // can also slide the picture while they are pinching: the scale comes from how far apart\n      // they are now, and the pan from how far their midpoint has travelled since. The previous\n      // midpoint has to be read before the ref is replaced — overwrite it first and the pan term\n      // is always zero, which looks like a pinch that works but refuses to be moved.\n      const previousCentre = pinch.current.centre\n      const zoomed = zoomAt(\n        transform,\n        anchor,\n        transform.scale * (spread / pinch.current.distance),\n        minScale,\n        maxScale\n      )\n      pinch.current = { distance: spread, centre: anchor }\n      commit({\n        ...zoomed,\n        x: zoomed.x + (anchor.x - previousCentre.x),\n        y: zoomed.y + (anchor.y - previousCentre.y),\n      })\n      return\n    }\n\n    if (!dragging) return\n    commit({\n      ...transform,\n      x: transform.x + (current.x - previous.x),\n      y: transform.y + (current.y - previous.y),\n    })\n  }\n\n  const endPointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    pointers.current.delete(event.pointerId)\n    event.currentTarget.releasePointerCapture?.(event.pointerId)\n    // The pinch is not cleared here on purpose. It is read only while two pointers are down, and\n    // the second pointer's own `pointerdown` always rewrites it first, so a value left behind by a\n    // finished gesture can never be reached — clearing it would be a line no test could hold.\n    if (pointers.current.size === 0) setDragging(false)\n  }\n\n  const handleDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {\n    if (isReset) zoomBy(DOUBLE_CLICK_STEP, pointFrom(event))\n    else commit(resting)\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.altKey || event.ctrlKey || event.metaKey) return\n    const pan: Record<string, ZoomPoint | undefined> = {\n      ArrowLeft: { x: panStep, y: 0 },\n      ArrowRight: { x: -panStep, y: 0 },\n      ArrowUp: { x: 0, y: panStep },\n      ArrowDown: { x: 0, y: -panStep },\n    }\n    // The arrows move the *view*, so the content slides the other way — pressing Right looks at\n    // what is to the right, the way it works in every map and every image viewer.\n    const nudge = pan[event.key]\n    if (nudge) {\n      if (!canZoomOut) return\n      event.preventDefault()\n      commit({ ...transform, x: transform.x + nudge.x, y: transform.y + nudge.y })\n      return\n    }\n    if (event.key === \"+\" || event.key === \"=\") {\n      event.preventDefault()\n      zoomBy(zoomStep)\n      return\n    }\n    if (event.key === \"-\" || event.key === \"_\") {\n      event.preventDefault()\n      zoomBy(1 / zoomStep)\n      return\n    }\n    if (event.key === \"0\") {\n      event.preventDefault()\n      commit(resting)\n    }\n  }\n\n  const button = (\n    label: string,\n    path: string,\n    enabled: boolean,\n    onPress: () => void\n  ) => (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-disabled={!enabled}\n      onClick={() => {\n        if (enabled) onPress()\n      }}\n      className={cn(\n        \"flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background/90 text-foreground shadow-sm backdrop-blur-sm transition-colors\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        enabled ? \"hover:bg-accent hover:text-accent-foreground\" : \"cursor-not-allowed opacity-50\"\n      )}\n    >\n      <svg\n        width=\"15\"\n        height=\"15\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden=\"true\"\n      >\n        <path d={path} />\n      </svg>\n    </button>\n  )\n\n  return (\n    <div\n      {...props}\n      ref={frameRef}\n      tabIndex={0}\n      role=\"group\"\n      aria-label={ariaLabel}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={endPointer}\n      onPointerCancel={endPointer}\n      onDoubleClick={handleDoubleClick}\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"relative isolate select-none overflow-hidden rounded-md border border-border bg-muted/20\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        canZoomOut ? (dragging ? \"cursor-grabbing\" : \"cursor-grab\") : \"cursor-zoom-in\",\n        className\n      )}\n      style={{\n        ...style,\n        // pan-y while the content fits, so a vertical swipe still scrolls the page and a\n        // full-width frame is not a trap on a phone. `none` once it does not, because from then on\n        // every touch in here is a pan or a pinch and the browser must not take them first. The\n        // cost is that the very first pinch at rest may be claimed by the browser instead — which\n        // is why the buttons and the double tap exist, and why they are never disabled on touch.\n        touchAction: canZoomOut ? \"none\" : \"pan-y\",\n      }}\n    >\n      {/*\n        In flow and full width, so the untransformed content is what gives the frame its height —\n        no aspect ratio to declare and no reflow when the image loads. The transform is painted\n        only, so the frame keeps that height at every zoom level.\n      */}\n      <div\n        ref={contentRef}\n        className=\"origin-top-left [&>*]:block [&>*]:h-auto [&>*]:w-full\"\n        style={{\n          transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,\n        }}\n      >\n        {children}\n      </div>\n\n      {showControls ? (\n        <div\n          className={cn(\"absolute right-2 top-2 z-10 flex items-center gap-1\", controlsClassName)}\n          // The buttons sit on the picture, which is also the drag surface. Without this a click\n          // on \"zoom in\" starts a drag underneath it and the picture lurches as the button is\n          // pressed; the buttons put their own pointer events back on.\n          onPointerDown={(event) => event.stopPropagation()}\n          onDoubleClick={(event) => event.stopPropagation()}\n        >\n          {/*\n            Announced politely rather than hidden, so a screen reader user pressing + hears where\n            they got to. It is the same text sighted users read, so there is no second copy to\n            drift out of step with this one.\n          */}\n          <span\n            aria-live=\"polite\"\n            aria-atomic=\"true\"\n            className=\"rounded-md border border-border bg-background/90 px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground shadow-sm backdrop-blur-sm\"\n          >\n            {Math.round(transform.scale * 100)}%\n          </span>\n          {button(\"Zoom out\", ICON.out, canZoomOut, () => zoomBy(1 / zoomStep))}\n          {button(\"Zoom in\", ICON.in, canZoomIn, () => zoomBy(zoomStep))}\n          {button(\"Reset zoom\", ICON.reset, !isReset, () => commit(resting))}\n        </div>\n      ) : null}\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 103 components: https://pulld.pages.dev/?utm_source=cli"
}
