{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image",
  "title": "Image",
  "description": "A figure with an optional caption, a native-dialog zoom sized from the picture's own proportions, and an edge-to-edge bleed for a landscape image.",
  "registryDependencies": [
    "https://www.deltacomponents.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ui/image.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface ImageProps\n  extends Omit<React.ComponentProps<\"img\">, \"alt\" | \"src\" | \"width\" | \"height\" | \"ref\"> {\n  src: string\n  /** Required. Pass \"\" for a decorative image. */\n  alt: string\n  caption?: React.ReactNode\n  /** Click or tap to enlarge. */\n  zoomable?: boolean\n  /** true: bleed below the `md` breakpoint. \"always\": at every width. Landscape images only. */\n  bleed?: boolean | \"always\"\n  width?: number\n  height?: number\n  onZoomChange?: (open: boolean) => void\n  /** Rendered inside the enlarged view, over the picture: an `ImageClose`,\n   *  say. An `ImageCaption` child is the one exception — it goes under the\n   *  picture, like the `caption` prop. The rest is ignored when `zoomable`\n   *  is false. */\n  children?: React.ReactNode\n  /** Classes for the <figure>; `className` goes on the <img>. */\n  figureClassName?: string\n}\n\n/** A figure: an image, an optional caption, a click-to-enlarge view in a\n *  native dialog, and an optional bleed past the text column's gutter. */\nfunction Image({\n  src,\n  alt,\n  caption,\n  zoomable = true,\n  bleed = false,\n  width,\n  height,\n  srcSet,\n  sizes,\n  onZoomChange,\n  children,\n  figureClassName,\n  className,\n  ...imgProps\n}: ImageProps) {\n  const thumbRef = React.useRef<HTMLImageElement>(null)\n  const dialogRef = React.useRef<HTMLDialogElement>(null)\n  const zoomImgRef = React.useRef<HTMLImageElement>(null)\n  // The pinch/pan gesture writes straight to the img's style, not React state\n  // (see motion-guidelines.md) — `pinch` is the current transform, `gesture`\n  // the in-progress touch(es) driving it.\n  const pinch = React.useRef<Pinch>({ scale: 1, x: 0, y: 0 })\n  const gesture = React.useRef<{\n    pinchStart: PinchStart | null\n    pan: { panX: number; panY: number } | null\n    // Where one finger landed on the unzoomed view; a swipe from there\n    // closes it, as scrolling the page does.\n    swipe: { x: number; y: number } | null\n  }>({ pinchStart: null, pan: null, swipe: null })\n  // A caption written as a child belongs to the figure, not the dialog, so\n  // the two are told apart here rather than by asking callers to nest twice.\n  const captionChildren: React.ReactNode[] = []\n  const overlayChildren: React.ReactNode[] = []\n  for (const child of React.Children.toArray(children)) {\n    const isCaption = React.isValidElement(child) && child.type === ImageCaption\n    if (isCaption) captionChildren.push(child)\n    else overlayChildren.push(child)\n  }\n  const [mounted, setMounted] = React.useState(false)\n  const [measuredRatio, setMeasuredRatio] = React.useState<number | null>(null)\n  const [loaded, setLoaded] = React.useState(false)\n\n  React.useEffect(() => setMounted(true), [])\n\n  // Set while the view is open; see openDialog.\n  const stopWatchingScroll = React.useRef<(() => void) | null>(null)\n  React.useEffect(() => () => stopWatchingScroll.current?.(), [])\n\n  // Known from the props when given; otherwise read off the thumbnail once\n  // its pixels are in. `width`/`height` land during SSR, so most callers only\n  // pay the effect for the loaded flag that ends the pulse.\n  const knownRatio = width && height ? width / height : undefined\n  const ratio = knownRatio ?? measuredRatio ?? undefined\n  // Unknown ratio reads as not landscape, so bleed never fires on a guess.\n  const landscape = ratio !== undefined && ratio > 1\n\n  React.useEffect(() => {\n    setLoaded(false)\n    const node = thumbRef.current\n    if (!node) return\n    const measure = () => {\n      setLoaded(true)\n      if (knownRatio || !node.naturalWidth || !node.naturalHeight) return\n      setMeasuredRatio(node.naturalWidth / node.naturalHeight)\n    }\n    // Now for anything already decoded from cache, and again on arrival.\n    if (node.complete) measure()\n    node.addEventListener(\"load\", measure)\n    return () => node.removeEventListener(\"load\", measure)\n  }, [knownRatio, src])\n\n  const bleedActive = landscape && Boolean(bleed)\n  const bleedAlways = bleed === \"always\"\n  // On the outer box (the button, or the img when there is no button) so a\n  // caption stays in the text column. The width is spelled out: `auto` only\n  // stretches a plain block — a button shrinks to fit and an img falls back\n  // to its natural size, so neither would follow the negative margins.\n  const bleedBoxClass = bleedActive\n    ? bleedAlways\n      ? \"mx-[calc(var(--image-bleed-gutter,1rem)*-1)] w-[calc(100%+2*var(--image-bleed-gutter,1rem))] max-w-none\"\n      : \"max-md:mx-[calc(var(--image-bleed-gutter,1rem)*-1)] max-md:w-[calc(100%+2*var(--image-bleed-gutter,1rem))] max-md:max-w-none\"\n    : undefined\n  // `settling` lifts `data-pinching` so the CSS transition carries the snap\n  // back to 1x or the clamp back into bounds; mid-gesture it stays off so the\n  // picture tracks the fingers with no lag.\n  const writePinch = (next: Pinch, settling: boolean) => {\n    pinch.current = next\n    const img = zoomImgRef.current\n    if (!img) return\n    img.style.setProperty(\"--pinch-scale\", String(next.scale))\n    img.style.setProperty(\"--pinch-x\", `${next.x}px`)\n    img.style.setProperty(\"--pinch-y\", `${next.y}px`)\n    if (settling) img.removeAttribute(\"data-pinching\")\n    else img.setAttribute(\"data-pinching\", \"\")\n  }\n\n  const onZoomTouchStart = (event: React.TouchEvent<HTMLImageElement>) => {\n    const img = zoomImgRef.current\n    if (!img) return\n    const touches = event.touches\n    if (touches.length >= 2) {\n      const a = touches[0]\n      const b = touches[1]\n      const rect = img.getBoundingClientRect()\n      const midX = (a.clientX + b.clientX) / 2\n      const midY = (a.clientY + b.clientY) / 2\n      gesture.current.pinchStart = {\n        ...pinch.current,\n        dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),\n        midX,\n        midY,\n        // Scale is about the box's centre, so the transformed box's centre is\n        // `center + translate` — subtracting the current translate recovers it.\n        centerX: rect.left + rect.width / 2 - pinch.current.x,\n        centerY: rect.top + rect.height / 2 - pinch.current.y,\n      }\n      gesture.current.pan = null\n      writePinch(pinch.current, false)\n    } else if (touches.length === 1 && pinch.current.scale > 1) {\n      const t = touches[0]\n      gesture.current.pan = { panX: t.clientX - pinch.current.x, panY: t.clientY - pinch.current.y }\n      writePinch(pinch.current, false)\n    }\n  }\n\n  const onZoomTouchMove = (event: React.TouchEvent<HTMLImageElement>) => {\n    const { pinchStart, pan } = gesture.current\n    const touches = event.touches\n    if (touches.length >= 2 && pinchStart) {\n      const a = touches[0]\n      const b = touches[1]\n      writePinch(\n        pinchTo(pinchStart, {\n          dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),\n          midX: (a.clientX + b.clientX) / 2,\n          midY: (a.clientY + b.clientY) / 2,\n        }),\n        false\n      )\n    } else if (touches.length === 1 && pan) {\n      const t = touches[0]\n      writePinch({ ...pinch.current, x: t.clientX - pan.panX, y: t.clientY - pan.panY }, false)\n    }\n  }\n\n  // On a phone the page under the view stays put (the view is `touch-none`,\n  // backdrop included), so a scroll can't close it the way it does on a\n  // desktop. One finger swiped anywhere — picture or backdrop — closes it\n  // instead. These see the picture's touches too, as they bubble, so a pinch\n  // or a pan in progress is left alone.\n  const onViewTouchStart = (event: React.TouchEvent<HTMLDialogElement>) => {\n    const t = event.touches[0]\n    gesture.current.swipe =\n      event.touches.length === 1 && pinch.current.scale === 1 ? { x: t.clientX, y: t.clientY } : null\n  }\n  const onViewTouchMove = (event: React.TouchEvent<HTMLDialogElement>) => {\n    const { swipe, pinchStart, pan } = gesture.current\n    if (!swipe || pinchStart || pan || event.touches.length !== 1) return\n    const t = event.touches[0]\n    if (Math.hypot(t.clientX - swipe.x, t.clientY - swipe.y) > 48) {\n      gesture.current.swipe = null\n      dialogRef.current?.close()\n    }\n  }\n\n  const onZoomTouchEnd = (event: React.TouchEvent<HTMLImageElement>) => {\n    const touches = event.touches\n    if (touches.length < 2) gesture.current.pinchStart = null\n    if (touches.length === 1 && pinch.current.scale > 1) {\n      // The finger still down keeps moving the picture rather than freezing it.\n      const t = touches[0]\n      gesture.current.pan = { panX: t.clientX - pinch.current.x, panY: t.clientY - pinch.current.y }\n      return\n    }\n    if (touches.length > 0) return\n    gesture.current.pan = null\n    const img = zoomImgRef.current\n    if (!img) return\n    const rect = img.getBoundingClientRect()\n    const box = { width: rect.width / pinch.current.scale, height: rect.height / pinch.current.scale }\n    writePinch(settlePinch(pinch.current, box), true)\n  }\n\n  const openDialog = () => {\n    const dialog = dialogRef.current\n    if (!dialog) return\n    dialog.showModal()\n    // showModal() focuses the first control inside, and a phone draws a focus\n    // ring on anything the page focuses itself, tap or not. Focus goes to the\n    // view instead: no ring after a tap, one Tab to the control by keyboard.\n    dialog.focus()\n    // A modal dialog blocks clicks on the page behind it, not scrolling. A\n    // reader who scrolls has moved on, so the view closes rather than trapping\n    // them. Desktop only in practice: on touch the view is `touch-none`, so\n    // the page never moves and the swipe handlers below close it instead.\n    const startY = window.scrollY\n    const closeOnceScrolled = () => {\n      if (Math.abs(window.scrollY - startY) > 48) dialog.close()\n    }\n    window.addEventListener(\"scroll\", closeOnceScrolled, { passive: true })\n    stopWatchingScroll.current = () => window.removeEventListener(\"scroll\", closeOnceScrolled)\n    onZoomChange?.(true)\n  }\n\n  const thumbnail = (\n    /* eslint-disable-next-line @next/next/no-img-element -- a registry\n       component installs into any React project, so it must not depend on\n       next/image; the consumer swaps this for their framework's loader. */\n    <img\n      ref={thumbRef}\n      src={src}\n      alt={alt}\n      width={width}\n      height={height}\n      srcSet={srcSet}\n      sizes={sizes}\n      loading=\"lazy\"\n      decoding=\"async\"\n      className={cn(\n        \"block h-auto w-full\",\n        // Pulses the image's own box (sized by width/height, or the\n        // aspect-ratio the browser maps from them) — no wrapper element, so\n        // nothing fights the bleed margins below.\n        !loaded && \"bg-muted animate-pulse\",\n        !zoomable && bleedBoxClass,\n        className\n      )}\n      {...imgProps}\n    />\n  )\n\n  return (\n    <figure data-slot=\"image\" className={cn(figureClassName)}>\n      {zoomable ? (\n        <button\n          type=\"button\"\n          onClick={openDialog}\n          aria-label={alt ? `Enlarge: ${alt}` : \"Enlarge image\"}\n          className={cn(\n            \"block w-full cursor-zoom-in\",\n            bleedBoxClass,\n            // A press dips the picture, so it reads as something that opens\n            // rather than a picture that happens to be there. 0.98, not the\n            // 0.96 a button takes: 2% of a picture is already a few pixels.\n            // Moderate, not fast: a scale across a whole picture is travel,\n            // and 80ms of it reads as a snap. The release is one tier quicker.\n            \"transition-[scale] duration-(--motion-moderate-exit) ease-spring active:scale-[0.98] active:duration-(--motion-moderate) [-webkit-tap-highlight-color:transparent]\",\n            // A tap holds :active for less than the press-in, so on touch the\n            // picture was still going down when the finger lifted and the\n            // transition flipped mid-flight, which reads as no easing at all.\n            // The press lands at once there and only the release eases — the\n            // same shape as Button's wash on a phone.\n            \"pointer-coarse:active:duration-0\",\n            // `rounded-none` beats the base-layer `:focus-visible` radius, which\n            // would round the ring's corners off a square picture.\n            \"rounded-none outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[color:var(--focus-ring,#6B97FF)]\"\n          )}\n        >\n          {thumbnail}\n        </button>\n      ) : (\n        thumbnail\n      )}\n\n      {caption && <ImageCaption>{caption}</ImageCaption>}\n      {captionChildren}\n\n      {zoomable &&\n        mounted &&\n        // Portaled to <body>, not rendered in place. A dialog promoted to the\n        // top layer from inside a clipped ancestor (an `overflow-x: clip`\n        // column, say) leaves WebKit painting its backdrop after close — dark\n        // bands down both page edges on iOS. From <body> there's no clipped\n        // ancestor to leave anything behind in. `mounted` keeps this SSR-safe.\n        createPortal(\n          <dialog\n            ref={dialogRef}\n            tabIndex={-1}\n            onClick={() => dialogRef.current?.close()}\n            onTouchStart={onViewTouchStart}\n            onTouchMove={onViewTouchMove}\n            onClose={() => {\n              stopWatchingScroll.current?.()\n              stopWatchingScroll.current = null\n              writePinch({ scale: 1, x: 0, y: 0 }, true)\n              onZoomChange?.(false)\n            }}\n            aria-label={\n              alt\n                ? `${alt} (enlarged). Press Escape to close.`\n                : \"Enlarged image. Press Escape to close.\"\n            }\n            style={{ \"--zoom-ratio\": ratio ?? 1 } as React.CSSProperties}\n            className={cn(\n              // `w-fit`, not `w-auto`: the UA centres a dialog with\n              // `inset-inline: 0` + `margin: auto`, which only centres a\n              // shrink-to-fit width. `auto` stretches across the viewport and\n              // leaves the image pinned to its left edge.\n              \"group m-auto max-h-none w-screen max-w-none border-0 bg-transparent p-0 md:w-fit\",\n              // The view holds focus while open (see openDialog), and the\n              // browser would ring it, boxing the one thing the reader opened\n              // it to look at. The backdrop already says where focus is, and\n              // Escape or a click anywhere closes it, so the ring adds nothing.\n              // `rounded-none` for the same reason: a phone treats that focus\n              // as focus-visible, and the base-layer radius that comes with it\n              // clipped the corners off a full-width picture.\n              // The UA gives a modal dialog `overflow: auto`, which would clip\n              // a pinch-zoomed picture into a scrollable box instead of\n              // letting it grow over the backdrop.\n              // `touch-none` covers the backdrop too — a touch on it is\n              // hit-tested to the dialog — so a phone never scrolls the page\n              // under the view; a swipe closes it (see onViewTouchMove).\n              \"rounded-none outline-none overflow-visible cursor-zoom-out touch-none\",\n              // It fades and grows in, in CSS alone. There is no matching\n              // exit: `close()` drops a dialog from the top layer at once.\n              // ponytail: `overlay` and `display` are listed for the day a\n              // browser defers that, as it already does for popovers.\n              // Only the fade lives here. The grow is on the image below: a\n              // scale on the dialog would make it the reference box for the\n              // close control, pinning that to the picture, not the screen.\n              \"opacity-0 transition-[opacity,overlay,display] transition-discrete\",\n              \"duration-(--motion-moderate) ease-spring\",\n              \"open:opacity-100\",\n              \"starting:open:opacity-0\",\n              // The page's own colour, not black: the veil is light on a light\n              // screen and dark on a dark one, so opening a picture never\n              // flips the room.\n              \"backdrop:bg-background/90 backdrop:opacity-0\",\n              \"backdrop:transition-opacity backdrop:duration-(--motion-moderate) backdrop:ease-spring\",\n              \"open:backdrop:opacity-100 starting:open:backdrop:opacity-0\"\n            )}\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element -- see the\n                thumbnail above. */}\n            <img\n              ref={zoomImgRef}\n              src={src}\n              alt={alt}\n              srcSet={srcSet}\n              sizes={srcSet ? \"100vw\" : sizes}\n              draggable={false}\n              onClick={(event) => {\n                // A tap that only ends a zoomed picture's gesture shouldn't\n                // also bubble to the dialog's own click-anywhere-closes.\n                if (pinch.current.scale > 1) event.stopPropagation()\n              }}\n              onTouchStart={onZoomTouchStart}\n              onTouchMove={onZoomTouchMove}\n              onTouchEnd={onZoomTouchEnd}\n              onTouchCancel={onZoomTouchEnd}\n              className={cn(\n                \"block h-auto max-h-dvh w-full max-w-none object-contain select-none\",\n                \"scale-95 transition-[scale,translate] duration-(--motion-moderate) ease-spring\",\n                \"group-open:scale-[var(--pinch-scale,1)] starting:group-open:scale-95\",\n                \"translate-x-[var(--pinch-x,0px)] translate-y-[var(--pinch-y,0px)]\",\n                // The gesture drives the picture directly; the transition only\n                // carries the settle after the fingers lift. Pinch/pan is\n                // touch-only by design — a trackpad pinch here stays the\n                // browser's own page zoom.\n                \"touch-none data-[pinching]:transition-none [-webkit-touch-callout:none]\",\n                // From md the box is sized, not just capped, by the viewport:\n                // `min()` picks whichever edge binds first, a landscape image\n                // meeting top/bottom and a portrait one meeting the sides.\n                // `lvh`, not `dvh`, here — the dialog doesn't scroll, and\n                // `dvh` would resize it on every mobile URL-bar animation.\n                \"md:h-auto md:max-h-[100lvh] md:max-w-[100vw] md:w-[min(100vw,calc(100lvh*var(--zoom-ratio,1)))]\"\n              )}\n            />\n            {overlayChildren}\n          </dialog>,\n          document.body\n        )}\n    </figure>\n  )\n}\n\ntype ImageCaptionProps = React.ComponentProps<\"figcaption\">\n\n/** The line under the picture. `Image` renders its `caption` prop through\n *  this; pass it as a child instead to change its classes or markup. */\nfunction ImageCaption({ className, ...props }: ImageCaptionProps) {\n  return (\n    <figcaption\n      data-slot=\"image-caption\"\n      className={cn(\"text-muted-foreground mt-2 text-center text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\ntype ImageCloseProps = React.ComponentProps<\"span\">\n\n/** Holds a close button in the enlarged view. It needs no handler: any click\n *  inside that view closes it, this one included. */\nfunction ImageClose({ className, ...props }: ImageCloseProps) {\n  return (\n    <span\n      data-slot=\"image-close\"\n      className={cn(\n        // The screen's corner, not the picture's, so it is in the same place\n        // for every image; `max()` keeps it clear of a notch. `cursor-auto`\n        // because the dialog around it says zoom-out. The radius variable is\n        // the one the registry's Button reads, so a Button here is a pill\n        // unless it says otherwise.\n        \"fixed top-[max(0.75rem,env(safe-area-inset-top))] right-[max(0.75rem,env(safe-area-inset-right))]\",\n        \"cursor-auto [--radius-button:9999px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst MAX_ZOOM = 4\n// Under this the pinch was a fumble, not a zoom, and the picture goes back.\nconst SNAP_BACK_BELOW = 1.05\n\ntype Pinch = { scale: number; x: number; y: number }\ntype PinchStart = Pinch & {\n  dist: number\n  midX: number\n  midY: number\n  // The picture's untransformed centre, in client coordinates.\n  centerX: number\n  centerY: number\n}\n\n/** Where the picture goes so the point between the two fingers stays under them. */\nfunction pinchTo(start: PinchStart, now: { dist: number; midX: number; midY: number }): Pinch {\n  const scale = Math.min(MAX_ZOOM, Math.max(1, (start.scale * now.dist) / start.dist))\n  const k = scale / start.scale\n  return {\n    scale,\n    x: now.midX - start.centerX - k * (start.midX - start.centerX - start.x),\n    y: now.midY - start.centerY - k * (start.midY - start.centerY - start.y),\n  }\n}\n\n/** Where the picture settles when the fingers lift: back to 1x after a fumble,\n *  otherwise pulled back so it still covers its own box. */\nfunction settlePinch(pinch: Pinch, box: { width: number; height: number }): Pinch {\n  if (pinch.scale < SNAP_BACK_BELOW) return { scale: 1, x: 0, y: 0 }\n  const maxX = ((pinch.scale - 1) * box.width) / 2\n  const maxY = ((pinch.scale - 1) * box.height) / 2\n  return {\n    scale: pinch.scale,\n    x: Math.min(maxX, Math.max(-maxX, pinch.x)),\n    y: Math.min(maxY, Math.max(-maxY, pinch.y)),\n  }\n}\n\nexport { Image, ImageCaption, ImageClose, pinchTo, settlePinch }\nexport type { ImageProps, ImageCaptionProps, ImageCloseProps }\n",
      "type": "registry:ui",
      "target": "components/ui/image.tsx"
    }
  ],
  "docs": "Docs & live playground: https://www.deltacomponents.dev/docs/image.",
  "categories": [
    "components"
  ],
  "type": "registry:ui"
}
