{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-gallery",
  "title": "Product Gallery",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "The product's photos: a main image on your shadcn Carousel with a counter, and thumbnails beside it on a desktop and under it on a phone (scrolling without a scrollbar). Swipe on touch; with a mouse the photo zooms where the pointer is. Pressing the photo opens it full screen on your shadcn Dialog — the screen grows out of the photo and folds back into it (Motion; a fade under reduced motion) — which is also the zoom for touch and the keyboard. The active thumbnail's frame slides to the next; the rest sit at 60%. The first photo loads with priority, the second early, the rest when they are next. Images are data rendered as <img>, or through your renderImage (next/image, say). Compound, through useProductGallery().",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "carousel",
    "dialog"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/product-gallery/product-gallery.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ImageOff, Maximize2, X } from \"lucide-react\"\nimport { MotionConfig, animate, motion, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\"\n\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNext,\n  CarouselPrevious,\n  type CarouselApi,\n} from \"@/components/ui/carousel\"\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * The product's photos: a main image with a counter, and a rail of thumbnails\n * beside it on a desktop and under it on a phone.\n *\n *   <ProductGallery images={images} label=\"Vester Chelsea Boot\" />\n *\n *   <ProductGallery images={images}>                                   or composed\n *     <ProductGalleryMain />\n *     <ProductGalleryThumbs />\n *   </ProductGallery>\n *\n * Swipe on a phone; on a desktop the mouse zooms where it points, and the\n * thumbnails, arrows and arrow keys move between photos. Pressing the photo\n * opens it full screen — the screen grows out of the photo and folds back\n * into it — which is also the zoom for touch and the keyboard. The active\n * thumbnail carries a solid frame that slides to the next one; the rest drop\n * to 60%. The first photo loads at once, the second is fetched early, and\n * the rest wait until they are next.\n *\n * Images are data, rendered as <img> by default. Pass `renderImage` to use\n * your framework's image component instead; the gallery still decides which\n * one has priority and which load lazily.\n */\n\nexport interface GalleryImage {\n  src: string\n  alt: string\n  srcSet?: string\n  /** A larger file for the zoom and the full-screen view. Defaults to `src`. */\n  zoomSrc?: string\n  /** A smaller file for the thumbnail. Defaults to `src`. */\n  thumbSrc?: string\n}\n\nexport interface GalleryImageRenderProps {\n  index: number\n  /** The first photo: render with your framework's priority / preload. */\n  priority: boolean\n  loading: \"eager\" | \"lazy\"\n  sizes: string\n  className: string\n}\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\n/** Scrolls, without drawing a scrollbar. */\nconst NO_SCROLLBAR = \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n\n/** A mouse zooms; a finger swipes. Embla drags only for touch and pen. */\nconst touchOnly = (_api: unknown, event: MouseEvent | TouchEvent) =>\n  !(\"pointerType\" in event) || (event as PointerEvent).pointerType !== \"mouse\"\n\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface ProductGalleryContextValue {\n  images: GalleryImage[]\n  /** The photo on show. */\n  index: number\n  setIndex: (index: number) => void\n  /** \"Vester Chelsea Boot\" — names the gallery and its full-screen view. */\n  label: string\n  /** Width over height of the main frame. */\n  aspectRatio: number\n  sizes: string\n  zoom: boolean\n  expanded: boolean\n  setExpanded: (expanded: boolean) => void\n  loading: boolean\n  renderImage: (image: GalleryImage, props: GalleryImageRenderProps) => React.ReactNode\n  /** The furthest photo shown so far: the next one after it loads early. */\n  reached: number\n}\n\n/** Internal: the morph's anchor and the primitive's lifetime. */\ninterface GalleryInternals {\n  frame: React.RefObject<HTMLDivElement | null>\n  mounted: boolean\n  closing: boolean\n  finishClose: () => void\n}\n\nconst ProductGalleryContext = React.createContext<ProductGalleryContextValue | null>(null)\nconst InternalsContext = React.createContext<GalleryInternals | null>(null)\n\nexport function useProductGallery() {\n  const context = React.useContext(ProductGalleryContext)\n  if (!context) {\n    throw new Error(\"useProductGallery must be used within <ProductGallery>.\")\n  }\n  return context\n}\n\nfunction useInternals() {\n  const context = React.useContext(InternalsContext)\n  if (!context) throw new Error(\"Product gallery parts must be used within <ProductGallery>.\")\n  return context\n}\n\nfunction defaultRenderImage(image: GalleryImage, props: GalleryImageRenderProps) {\n  return (\n    // eslint-disable-next-line @next/next/no-img-element -- framework-free on purpose\n    <img\n      src={image.src}\n      srcSet={image.srcSet}\n      sizes={props.sizes}\n      alt={image.alt}\n      loading={props.loading}\n      fetchPriority={props.priority ? \"high\" : props.index === 1 ? \"low\" : \"auto\"}\n      decoding=\"async\"\n      draggable={false}\n      className={props.className}\n    />\n  )\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface ProductGalleryProps extends React.HTMLAttributes<HTMLDivElement> {\n  images: GalleryImage[]\n  /** Names the gallery for screen readers: the product's name. */\n  label?: string\n  index?: number\n  defaultIndex?: number\n  onIndexChange?: (index: number) => void\n  /** Width over height of the main frame. Defaults to 4 / 5. */\n  aspectRatio?: number\n  /** The main photo's `sizes`. Defaults to half the screen from md up. */\n  sizes?: string\n  /** Hover zoom with a mouse. Defaults to true. */\n  zoom?: boolean\n  /** Render each photo with your own component — next/image, say. */\n  renderImage?: (image: GalleryImage, props: GalleryImageRenderProps) => React.ReactNode\n  loading?: boolean\n}\n\nexport function ProductGallery({\n  images,\n  label = \"Product images\",\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  aspectRatio = 4 / 5,\n  sizes = \"(min-width: 768px) 50vw, 100vw\",\n  zoom = true,\n  renderImage = defaultRenderImage,\n  loading = false,\n  className,\n  children,\n  ...props\n}: ProductGalleryProps) {\n  const [rawIndex, setIndexState] = useControllableState(indexProp, defaultIndex, onIndexChange)\n  const last = Math.max(0, images.length - 1)\n  const index = Math.min(Math.max(0, rawIndex), last)\n\n  // The furthest photo reached; the one after it is fetched early.\n  const [reached, setReached] = React.useState(index)\n  if (index > reached) setReached(index)\n\n  // A new set of photos — another colour — starts again from the first.\n  // In an effect: a controlled parent's onIndexChange must not run mid-render.\n  const firstSrc = images[0]?.src\n  const shownSet = React.useRef(firstSrc)\n  React.useEffect(() => {\n    if (shownSet.current === firstSrc) return\n    shownSet.current = firstSrc\n    setIndexState(0)\n  }, [firstSrc, setIndexState])\n\n  // Full screen: `expanded` is what the shopper asked for; `mounted` keeps the\n  // dialog on screen while it folds back into the photo.\n  const [expanded, setExpandedState] = React.useState(false)\n  const [mounted, setMounted] = React.useState(false)\n  if (expanded && !mounted) setMounted(true)\n  const closing = mounted && !expanded\n  const frame = React.useRef<HTMLDivElement>(null)\n  const indexRef = React.useRef(0)\n  // The dialog opens from the photo, not from a trigger, so the primitive has\n  // nothing to hand focus back to: return it to the photo now on show.\n  const finishClose = React.useCallback(() => {\n    setMounted(false)\n    window.requestAnimationFrame(() => {\n      frame.current\n        ?.querySelectorAll<HTMLElement>('[data-slot=\"carousel-item\"] button')\n        [indexRef.current]?.focus({ preventScroll: true })\n    })\n  }, [])\n\n  const setIndex = React.useCallback(\n    (next: number) => setIndexState(Math.min(Math.max(0, next), last)),\n    [setIndexState, last]\n  )\n  React.useEffect(() => {\n    indexRef.current = index\n  }, [index])\n\n  const context = React.useMemo<ProductGalleryContextValue>(\n    () => ({\n      images,\n      index,\n      setIndex,\n      label,\n      aspectRatio,\n      sizes,\n      zoom,\n      expanded,\n      setExpanded: setExpandedState,\n      loading,\n      renderImage,\n      reached,\n    }),\n    [images, index, setIndex, label, aspectRatio, sizes, zoom, expanded, loading, renderImage, reached]\n  )\n  const internals = React.useMemo<GalleryInternals>(\n    () => ({ frame, mounted, closing, finishClose }),\n    [mounted, closing, finishClose]\n  )\n\n  return (\n    <ProductGalleryContext.Provider value={context}>\n      <InternalsContext.Provider value={internals}>\n        <MotionConfig reducedMotion=\"user\">\n          <div\n            role=\"region\"\n            aria-label={label}\n            aria-busy={loading || undefined}\n            data-slot=\"product-gallery\"\n            className={cn(\n              children ? \"flex flex-col gap-3\" : \"grid gap-3 md:grid-cols-[4.5rem_minmax(0,1fr)] md:gap-4\",\n              className\n            )}\n            {...props}\n          >\n            {children ?? (\n              <>\n                <ProductGalleryMain className=\"md:col-start-2 md:row-start-1\" />\n                <ProductGalleryThumbs className=\"md:col-start-1 md:row-start-1\" />\n              </>\n            )}\n          </div>\n          {mounted ? <ProductGalleryLightbox /> : null}\n        </MotionConfig>\n      </InternalsContext.Provider>\n    </ProductGalleryContext.Provider>\n  )\n}\n\n/* ─── main ─────────────────────────────────────────────────────────────── */\n\n/**\n * The main photo, on your shadcn Carousel: swipe on touch, the arrows over\n * its edges, the arrow keys once it has focus. A mouse over it zooms where it\n * points. Pressing it opens the photo full screen.\n */\nexport function ProductGalleryMain({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { images, index, setIndex, label, aspectRatio, loading, setExpanded } = useProductGallery()\n  const { frame } = useInternals()\n  const [api, setApi] = React.useState<CarouselApi>()\n\n  // The carousel follows the index — a thumbnail, the full-screen view, a\n  // controlled prop — and the index follows the carousel — a swipe, a key.\n  React.useEffect(() => {\n    if (!api) return\n    const onSelect = () => setIndex(api.selectedScrollSnap())\n    api.on(\"select\", onSelect)\n    return () => {\n      api.off(\"select\", onSelect)\n    }\n  }, [api, setIndex])\n  React.useEffect(() => {\n    if (api && api.selectedScrollSnap() !== index) api.scrollTo(index)\n  }, [api, index])\n\n  if (loading) {\n    return (\n      <div\n        aria-hidden\n        className={cn(\"w-full animate-pulse bg-muted\", className)}\n        style={{ aspectRatio }}\n      />\n    )\n  }\n\n  if (!images.length) {\n    return (\n      <div\n        data-slot=\"product-gallery-empty\"\n        className={cn(\"grid w-full place-items-center bg-secondary text-muted-foreground\", className)}\n        style={{ aspectRatio }}\n      >\n        <span className=\"flex flex-col items-center gap-2 text-sm\">\n          <ImageOff aria-hidden className=\"size-5\" />\n          No photos yet\n        </span>\n      </div>\n    )\n  }\n\n  const image = images[index]\n\n  return (\n    <div ref={frame} data-slot=\"product-gallery-main\" className={cn(\"group/gallery relative min-w-0\", className)} {...props}>\n      <Carousel\n        setApi={setApi}\n        opts={{ startIndex: index, watchDrag: touchOnly }}\n        aria-label={label}\n        className=\"bg-secondary\"\n      >\n        <CarouselContent className=\"ml-0\">\n          {images.map((img, i) => (\n            <CarouselItem key={`${img.src}-${i}`} aria-label={`${i + 1} of ${images.length}`} className=\"pl-0\">\n              <GallerySlide image={img} index={i} onOpen={() => setExpanded(true)} />\n            </CarouselItem>\n          ))}\n        </CarouselContent>\n        {images.length > 1 ? (\n          <>\n            <CarouselPrevious className=\"left-3 size-9 rounded-none border-0 bg-background/90 opacity-0 shadow-none transition-opacity group-hover/gallery:opacity-100 focus-visible:opacity-100 disabled:opacity-0 [@media(hover:none)]:hidden\" />\n            <CarouselNext className=\"right-3 size-9 rounded-none border-0 bg-background/90 opacity-0 shadow-none transition-opacity group-hover/gallery:opacity-100 focus-visible:opacity-100 disabled:opacity-0 [@media(hover:none)]:hidden\" />\n          </>\n        ) : null}\n      </Carousel>\n\n      {images.length > 1 ? (\n        <span\n          aria-hidden\n          data-slot=\"product-gallery-counter\"\n          className=\"pointer-events-none absolute bottom-3 left-3 bg-background/90 px-2 py-1 text-[11px] tracking-[0.14em] tabular-nums\"\n        >\n          {index + 1} / {images.length}\n        </span>\n      ) : null}\n      <span aria-hidden className=\"pointer-events-none absolute top-3 right-3 grid size-9 place-items-center bg-background/90\">\n        <Maximize2 className=\"size-4\" />\n      </span>\n      {/* Read out on every change: which photo, and what it shows. */}\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {`Image ${index + 1} of ${images.length}: ${image?.alt ?? \"\"}`}\n      </span>\n    </div>\n  )\n}\n\n/**\n * One photo: a button that opens the full-screen view, and — with a mouse —\n * a zoom that follows the pointer. The larger file fades in over the photo\n * once it has loaded.\n */\nfunction GallerySlide({ image, index, onOpen }: { image: GalleryImage; index: number; onOpen: () => void }) {\n  const { aspectRatio, sizes, zoom, renderImage, reached, images } = useProductGallery()\n  const reduce = useReducedMotion()\n  const surface = React.useRef<HTMLDivElement>(null)\n  const [zoomed, setZoomed] = React.useState(false)\n  const [sharp, setSharp] = React.useState(false)\n  const zoomSrc = image.zoomSrc ?? image.src\n  const canZoom = zoom && !reduce\n\n  const aim = (event: React.PointerEvent) => {\n    const node = surface.current\n    if (!node) return\n    const box = event.currentTarget.getBoundingClientRect()\n    const x = ((event.clientX - box.left) / box.width) * 100\n    const y = ((event.clientY - box.top) / box.height) * 100\n    node.style.transformOrigin = `${x}% ${y}%`\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onOpen}\n      aria-label={`View image ${index + 1} of ${images.length} full screen: ${image.alt}`}\n      onPointerEnter={(event) => {\n        if (!canZoom || event.pointerType !== \"mouse\") return\n        aim(event)\n        setZoomed(true)\n      }}\n      onPointerMove={(event) => {\n        if (zoomed) aim(event)\n      }}\n      onPointerLeave={() => setZoomed(false)}\n      className={cn(\n        \"relative block w-full overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n        canZoom ? \"cursor-zoom-in\" : \"cursor-pointer\"\n      )}\n      style={{ aspectRatio }}\n    >\n      <motion.div\n        ref={surface}\n        className=\"absolute inset-0\"\n        initial={false}\n        animate={{ scale: zoomed ? 2 : 1 }}\n        transition={MORPH}\n      >\n        {renderImage(image, {\n          index,\n          priority: index === 0,\n          // The first two load with the page; the rest once they are next.\n          loading: index <= Math.max(1, reached + 1) ? \"eager\" : \"lazy\",\n          sizes,\n          className: \"absolute inset-0 size-full object-cover\",\n        })}\n        {zoomed && zoomSrc !== image.src ? (\n          // eslint-disable-next-line @next/next/no-img-element -- the zoom's larger file\n          <img\n            src={zoomSrc}\n            alt=\"\"\n            aria-hidden\n            draggable={false}\n            onLoad={() => setSharp(true)}\n            className={cn(\n              \"absolute inset-0 size-full object-cover transition-opacity duration-300\",\n              sharp ? \"opacity-100\" : \"opacity-0\"\n            )}\n          />\n        ) : null}\n      </motion.div>\n    </button>\n  )\n}\n\n/* ─── thumbnails ───────────────────────────────────────────────────────── */\n\n/**\n * The thumbnails: a column beside the photo from md up, a row under it on a\n * phone, scrolling without a scrollbar. The active one has a solid frame\n * that slides to the next; the rest sit at 60%, not greyed out.\n */\nexport function ProductGalleryThumbs({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { images, index, setIndex, label, aspectRatio, loading } = useProductGallery()\n  const reduce = useReducedMotion()\n  const scope = React.useId()\n  const rail = React.useRef<HTMLDivElement>(null)\n\n  // Keep the active thumbnail in view — by scrolling the rail itself, never\n  // the page, as scrollIntoView would.\n  React.useEffect(() => {\n    const node = rail.current\n    const active = node?.querySelector<HTMLElement>('[aria-current=\"true\"]')\n    if (!node || !active) return\n    const vertical = node.scrollHeight > node.clientHeight && node.scrollWidth <= node.clientWidth\n    if (vertical) {\n      const top = active.offsetTop - node.offsetTop\n      if (top < node.scrollTop || top + active.offsetHeight > node.scrollTop + node.clientHeight) {\n        node.scrollTo({ top: top - node.clientHeight / 2 + active.offsetHeight / 2, behavior: reduce ? \"auto\" : \"smooth\" })\n      }\n    } else {\n      const left = active.offsetLeft - node.offsetLeft\n      if (left < node.scrollLeft || left + active.offsetWidth > node.scrollLeft + node.clientWidth) {\n        node.scrollTo({ left: left - node.clientWidth / 2 + active.offsetWidth / 2, behavior: reduce ? \"auto\" : \"smooth\" })\n      }\n    }\n  }, [index, reduce])\n\n  if (loading) {\n    return (\n      <div aria-hidden className={cn(\"flex gap-2 md:flex-col\", className)}>\n        {Array.from({ length: 4 }, (_, i) => (\n          <span key={i} className=\"w-16 shrink-0 animate-pulse bg-muted md:w-full\" style={{ aspectRatio }} />\n        ))}\n      </div>\n    )\n  }\n  if (images.length < 2) return null\n\n  return (\n    <div\n      ref={rail}\n      role=\"group\"\n      aria-label={`${label}: choose an image`}\n      data-slot=\"product-gallery-thumbs\"\n      className={cn(\n        \"flex gap-2 overflow-x-auto overscroll-x-contain p-0.5 md:max-h-[min(80dvh,44rem)] md:flex-col md:overflow-x-visible md:overflow-y-auto md:overscroll-y-contain\",\n        NO_SCROLLBAR,\n        className\n      )}\n      {...props}\n    >\n      {images.map((image, i) => {\n        const active = i === index\n        return (\n          <button\n            key={`${image.src}-${i}`}\n            type=\"button\"\n            aria-current={active ? \"true\" : undefined}\n            aria-label={`Image ${i + 1} of ${images.length}: ${image.alt}`}\n            onClick={() => setIndex(i)}\n            className={cn(\n              \"relative w-16 shrink-0 bg-secondary outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring md:w-full\",\n              active ? \"opacity-100\" : \"opacity-60 hover:opacity-100\"\n            )}\n            style={{ aspectRatio }}\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element -- framework-free on purpose */}\n            <img\n              src={image.thumbSrc ?? image.src}\n              alt=\"\"\n              loading=\"lazy\"\n              decoding=\"async\"\n              draggable={false}\n              className=\"absolute inset-0 size-full object-cover\"\n            />\n            {active ? (\n              <motion.span\n                aria-hidden\n                layoutId={reduce ? undefined : `${scope}-active`}\n                initial={reduce ? { opacity: 0 } : false}\n                animate={{ opacity: 1 }}\n                transition={reduce ? { duration: 0.15 } : MORPH}\n                className=\"absolute inset-0 border-2 border-foreground\"\n              />\n            ) : null}\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n\n/* ─── full screen ──────────────────────────────────────────────────────── */\n\n/**\n * The full-screen view, on your shadcn Dialog. It grows out of the main\n * photo — the photo's box opening to the screen, the image fading in once it\n * has — and folds back into it on close. Swipe, the arrows or the arrow keys\n * move between photos, and the main photo follows.\n */\nfunction ProductGalleryLightbox() {\n  const { images, index, setIndex, label, expanded, setExpanded } = useProductGallery()\n  const [api, setApi] = React.useState<CarouselApi>()\n\n  React.useEffect(() => {\n    if (!api) return\n    const onSelect = () => setIndex(api.selectedScrollSnap())\n    api.on(\"select\", onSelect)\n    return () => {\n      api.off(\"select\", onSelect)\n    }\n  }, [api, setIndex])\n  React.useEffect(() => {\n    if (api && api.selectedScrollSnap() !== index) api.scrollTo(index)\n  }, [api, index])\n\n  return (\n    <Dialog\n      open\n      onOpenChange={(next) => {\n        // Escape pressed twice, or the overlay clicked mid-close: already going.\n        if (!next && expanded) setExpanded(false)\n      }}\n    >\n      <DialogContent\n        showCloseButton={false}\n        // The primitive positions and traps focus; its surface, padding and\n        // keyframes are off, and Motion plays the open and the close.\n        className=\"top-0 left-0 h-dvh w-screen max-w-none translate-x-0 translate-y-0 gap-0 rounded-none border-0 bg-transparent p-0 shadow-none ring-0 animate-none! transition-none! sm:max-w-none\"\n      >\n        <LightboxPanel>\n          <DialogTitle className=\"sr-only\">{label}</DialogTitle>\n          <DialogDescription className=\"sr-only\">\n            Full-screen photos. Use the arrow keys or swipe to move between them.\n          </DialogDescription>\n          <Carousel\n            setApi={setApi}\n            opts={{ startIndex: index }}\n            aria-label={label}\n            className=\"flex size-full flex-col\"\n          >\n            <div className=\"flex h-15 shrink-0 items-center justify-between gap-4 border-b px-4 sm:px-6\">\n              <span className=\"text-[11px] tracking-[0.14em] tabular-nums\">\n                {index + 1} / {images.length}\n              </span>\n              <DialogClose\n                className=\"grid size-9 place-items-center outline-none hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                <X className=\"size-4\" aria-hidden />\n                <span className=\"sr-only\">Close</span>\n              </DialogClose>\n            </div>\n            <div className=\"relative min-h-0 flex-1\">\n              <CarouselContent className=\"ml-0 h-[calc(100dvh-3.75rem)]\">\n                {images.map((image, i) => (\n                  <CarouselItem\n                    key={`${image.src}-${i}`}\n                    aria-label={`${i + 1} of ${images.length}`}\n                    className=\"h-full pl-0\"\n                  >\n                    {/* eslint-disable-next-line @next/next/no-img-element -- framework-free on purpose */}\n                    <img\n                      src={image.zoomSrc ?? image.src}\n                      alt={image.alt}\n                      loading={Math.abs(i - index) <= 1 ? \"eager\" : \"lazy\"}\n                      decoding=\"async\"\n                      draggable={false}\n                      // Fills the slide and fits inside it: the whole photo, never cropped.\n                      className=\"size-full object-contain\"\n                    />\n                  </CarouselItem>\n                ))}\n              </CarouselContent>\n              {images.length > 1 ? (\n                <>\n                  <CarouselPrevious className=\"left-3 size-10 rounded-none border-0 bg-background/90 shadow-none disabled:opacity-0 sm:left-6\" />\n                  <CarouselNext className=\"right-3 size-10 rounded-none border-0 bg-background/90 shadow-none disabled:opacity-0 sm:right-6\" />\n                </>\n              ) : null}\n            </div>\n          </Carousel>\n        </LightboxPanel>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\n/** The rectangle `anchor` occupies inside the viewport, as a clip-path inset. */\nfunction insetOf(anchor: HTMLElement | null) {\n  const box = anchor?.getBoundingClientRect()\n  if (!box || box.width === 0) return null\n  const w = window.innerWidth\n  const h = window.innerHeight\n  return `inset(${box.top}px ${w - box.right}px ${h - box.bottom}px ${box.left}px)`\n}\n\n/**\n * The visible surface, and the morph: clipped to the main photo's box, it\n * opens to the full screen; its contents fade in once it has. Closing runs\n * it back into the photo. In a layout effect inside the portal, so the first\n * frame is already clipped — it never flashes full screen.\n */\nfunction LightboxPanel({ children }: { children: React.ReactNode }) {\n  const { frame, closing, finishClose } = useInternals()\n  const reduce = useReducedMotion()\n  const surface = React.useRef<HTMLDivElement>(null)\n  const contents = React.useRef<HTMLDivElement>(null)\n\n  React.useLayoutEffect(() => {\n    const panel = surface.current\n    const inner = contents.current\n    if (!panel || !inner) return\n    const overlay = panel\n      .closest<HTMLElement>('[data-slot=\"dialog-content\"]')\n      ?.parentElement?.querySelector<HTMLElement>(':scope > [data-slot=\"dialog-overlay\"]')\n    const running: AnimationPlaybackControls[] = []\n    let cancelled = false\n    const from = reduce ? null : insetOf(frame.current)\n\n    if (!closing) {\n      if (from) {\n        panel.style.clipPath = from\n        inner.style.opacity = \"0\"\n        running.push(\n          animate(panel, { clipPath: [from, \"inset(0px 0px 0px 0px)\"] }, MORPH),\n          animate(inner, { opacity: 1 }, { duration: 0.25, delay: 0.2, ease: \"easeOut\" })\n        )\n      } else {\n        panel.style.opacity = \"0\"\n        running.push(animate(panel, { opacity: 1 }, { duration: 0.15 }))\n      }\n      return () => {\n        cancelled = true\n        running.forEach((a) => a.stop())\n      }\n    }\n\n    running.push(animate(inner, { opacity: 0 }, { duration: 0.12, ease: \"easeIn\" }))\n    if (from) {\n      running.push(animate(panel, { clipPath: from }, { ...MORPH, duration: 0.38, delay: 0.06 }))\n    } else {\n      running.push(animate(panel, { opacity: 0 }, { duration: 0.15 }))\n    }\n    if (overlay) running.push(animate(overlay, { opacity: 0 }, { duration: 0.3, delay: 0.1 }))\n\n    // Should a frame never come (a background tab), close anyway.\n    const fallback = window.setTimeout(() => !cancelled && finishClose(), 800)\n    Promise.all(running.map((a) => a.finished)).then(() => {\n      window.clearTimeout(fallback)\n      if (!cancelled) finishClose()\n    })\n    return () => {\n      cancelled = true\n      window.clearTimeout(fallback)\n      running.forEach((a) => a.stop())\n    }\n    // Runs on open and on close; `reduce` is read, not watched.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [closing])\n\n  return (\n    <div ref={surface} data-slot=\"product-gallery-lightbox\" className=\"size-full bg-background text-foreground\">\n      <div ref={contents} className=\"size-full\">\n        {children}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/product-gallery.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "product",
    "pdp",
    "media"
  ],
  "type": "registry:block"
}