{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-loader",
  "type": "registry:ui",
  "title": "Image Loader",
  "description": "A centered image sequence with smooth crossfades and a final image that expands to fill the frame.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/image-loader.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface ImageLoaderImage {\n  src: string;\n  alt?: string;\n  objectPosition?: string;\n}\n\nexport type ImageLoaderEase = \"power3.inOut\" | \"expo.inOut\" | \"sine.inOut\";\n\nexport interface ImageLoaderProps {\n  images: ImageLoaderImage[];\n  finalImage: ImageLoaderImage;\n  imageDuration?: number;\n  transitionDuration?: number;\n  holdDuration?: number;\n  revealDuration?: number;\n  initialDelay?: number;\n  thumbnailWidth?: number;\n  thumbnailAspectRatio?: number;\n  borderRadius?: number;\n  entranceScale?: number;\n  ease?: ImageLoaderEase;\n  background?: string;\n  paused?: boolean;\n  replayKey?: string | number;\n  label?: string;\n  children?: ReactNode;\n  className?: string;\n  onComplete?: () => void;\n  onImageError?: (src: string) => void;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nexport function ImageLoader({\n  images,\n  finalImage,\n  imageDuration = 0.22,\n  transitionDuration = 0.12,\n  holdDuration = 1,\n  revealDuration = 0.9,\n  initialDelay = 0.15,\n  thumbnailWidth = 240,\n  thumbnailAspectRatio = 1.6,\n  borderRadius = 6,\n  entranceScale = 0.85,\n  ease = \"power3.inOut\",\n  background = \"#191919\",\n  paused = false,\n  replayKey = 0,\n  label = \"Loading images\",\n  children,\n  className,\n  onComplete,\n  onImageError,\n}: ImageLoaderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const frameRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const timelineRef = useRef<gsap.core.Timeline | null>(null);\n  const callbacksRef = useRef({ onComplete, onImageError });\n  const pausedRef = useRef(paused);\n  const finishedRef = useRef(false);\n  const [complete, setComplete] = useState(false);\n  const [imageFailed, setImageFailed] = useState(false);\n  const sourceKey = JSON.stringify(images.map((image) => image.src));\n  const width = bounded(thumbnailWidth, 240, 64, 600);\n  const ratio = bounded(thumbnailAspectRatio, 1.6, 0.5, 3);\n  const radius = bounded(borderRadius, 6, 0, 40);\n  const scale = bounded(entranceScale, 0.85, 0.5, 1);\n  const dwell = bounded(imageDuration, 0.22, 0.05, 3);\n  const transition = bounded(transitionDuration, 0.12, 0.03, 1);\n  const hold = bounded(holdDuration, 1, 0, 5);\n  const reveal = bounded(revealDuration, 0.9, 0.2, 3);\n  const delay = bounded(initialDelay, 0.15, 0, 3);\n\n  useEffect(() => {\n    callbacksRef.current = { onComplete, onImageError };\n    pausedRef.current = paused;\n    timelineRef.current?.paused(paused);\n  }, [onComplete, onImageError, paused]);\n\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      finishedRef.current = false;\n      setComplete(false);\n\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          standard: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          const root = rootRef.current;\n          const frame = frameRef.current;\n          const content = contentRef.current;\n          if (!root || !frame || !content) return;\n\n          let active = true;\n          const unsubscribe: (() => void)[] = [];\n          const cards = gsap.utils.toArray<HTMLElement>(\n            \"[data-image-loader-card]\",\n            root,\n          );\n          const assets = gsap.utils.toArray<HTMLImageElement>(\n            \"[data-image-loader-asset]\",\n            root,\n          );\n          const finalAsset = frame.querySelector(\"img\");\n          const reduced = Boolean(context.conditions?.reduced);\n          const thumbnailSize = () =>\n            Math.min(width, root.clientWidth * 0.7, root.clientHeight * ratio * 0.7);\n\n          const finish = () => {\n            if (!active || finishedRef.current) return;\n            finishedRef.current = true;\n            setComplete(true);\n            callbacksRef.current.onComplete?.();\n          };\n\n          gsap.set(cards, { autoAlpha: 0, scale: 1 });\n          const sizeCards = context.add(\"sizeCards\", () => {\n            gsap.set(cards, {\n              width: thumbnailSize,\n              height: () => thumbnailSize() / ratio,\n            });\n          }) as () => void;\n          sizeCards();\n          gsap.set(frame, {\n            autoAlpha: finishedRef.current ? 1 : 0,\n            xPercent: -50,\n            yPercent: -50,\n            ...(finishedRef.current && {\n              width: \"100%\",\n              height: \"100%\",\n              borderRadius: 0,\n            }),\n          });\n          gsap.set(content, { autoAlpha: finishedRef.current ? 1 : 0, y: 0 });\n\n          const start = context.add(\"start\", () => {\n            if (!active) return;\n            const available = cards.filter(\n              (card) => (card.querySelector(\"img\")?.naturalWidth ?? 0) > 0,\n            );\n            setImageFailed(!finalAsset?.naturalWidth);\n            assets\n              .filter((image) => !image.naturalWidth)\n              .forEach((image) => callbacksRef.current.onImageError?.(image.src));\n\n            if (reduced || finishedRef.current) {\n              gsap.set(frame, {\n                autoAlpha: 1,\n                width: \"100%\",\n                height: \"100%\",\n                borderRadius: 0,\n              });\n              gsap.set(content, { autoAlpha: 1, y: 0 });\n              finish();\n              return;\n            }\n\n            const timeline = gsap.timeline({\n              paused: pausedRef.current,\n              defaults: { ease: \"power2.out\" },\n              onComplete: finish,\n            });\n            timelineRef.current = timeline;\n            const first = available[0];\n            if (first) {\n              timeline.fromTo(\n                first,\n                { autoAlpha: 0, scale },\n                { autoAlpha: 1, scale: 1, duration: transition * 1.5 },\n                delay,\n              );\n              available.forEach((card, index) => {\n                if (index === 0) return;\n                timeline\n                  .to(card, { autoAlpha: 1, duration: transition }, `>+=${dwell}`)\n                  .to(\n                    available[index - 1],\n                    { autoAlpha: 0, duration: transition },\n                    \"<\",\n                  );\n              });\n            }\n\n            timeline\n              .set(\n                frame,\n                {\n                  width: thumbnailSize,\n                  height: () => thumbnailSize() / ratio,\n                  borderRadius: radius,\n                },\n                `>+=${first ? hold : delay}`,\n              )\n              .to(frame, { autoAlpha: 1, duration: transition })\n              .to(cards, { autoAlpha: 0, duration: transition }, \"<\")\n              .addLabel(\"expand\")\n              .fromTo(\n                frame,\n                {\n                  width: thumbnailSize,\n                  height: () => thumbnailSize() / ratio,\n                  borderRadius: radius,\n                },\n                {\n                  width: \"100%\",\n                  height: \"100%\",\n                  borderRadius: 0,\n                  duration: reveal,\n                  ease,\n                  immediateRender: false,\n                },\n                \"expand\",\n              )\n              .fromTo(\n                content,\n                { autoAlpha: 0, y: 12 },\n                {\n                  autoAlpha: 1,\n                  y: 0,\n                  duration: 0.4,\n                  immediateRender: false,\n                },\n                `expand+=${reveal * 0.75}`,\n              );\n          }) as () => void;\n\n          const ready = assets.map((image) => {\n            if (image.complete) return Promise.resolve();\n            return new Promise<void>((resolve) => {\n              const settle = () => {\n                image.removeEventListener(\"load\", settle);\n                image.removeEventListener(\"error\", settle);\n                resolve();\n              };\n              image.addEventListener(\"load\", settle, { once: true });\n              image.addEventListener(\"error\", settle, { once: true });\n              unsubscribe.push(settle);\n            });\n          });\n\n          void Promise.all(ready)\n            .then(() =>\n              Promise.allSettled(\n                assets\n                  .filter((image) => image.naturalWidth > 0)\n                  .map((image) => image.decode()),\n              ),\n            )\n            .then(start);\n          let previousWidth = root.clientWidth;\n          let previousHeight = root.clientHeight;\n          const resize = new ResizeObserver(() => {\n            const nextWidth = root.clientWidth;\n            const nextHeight = root.clientHeight;\n            if (nextWidth === previousWidth && nextHeight === previousHeight) return;\n            previousWidth = nextWidth;\n            previousHeight = nextHeight;\n            sizeCards();\n            const timeline = timelineRef.current;\n            if (!timeline || finishedRef.current) return;\n            const time = timeline.time();\n            timeline.invalidate().time(time, true);\n          });\n          resize.observe(root);\n\n          return () => {\n            active = false;\n            unsubscribe.forEach((remove) => remove());\n            resize.disconnect();\n            timelineRef.current = null;\n          };\n        },\n        rootRef,\n      );\n\n      return () => media.revert();\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        sourceKey,\n        finalImage.src,\n        width,\n        ratio,\n        radius,\n        scale,\n        dwell,\n        transition,\n        hold,\n        reveal,\n        delay,\n        ease,\n        replayKey,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      aria-busy={!complete}\n      data-slot=\"image-loader\"\n      className={cn(\"relative isolate min-h-80 w-full overflow-hidden\", className)}\n      style={{ backgroundColor: background }}\n    >\n      <div aria-hidden=\"true\" className=\"absolute inset-0\">\n        {images.map((image, index) => (\n          <div\n            key={`${image.src}-${index}`}\n            data-image-loader-card\n            className=\"invisible absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 overflow-hidden opacity-0\"\n            style={{\n              width: `min(${width}px, 70%)`,\n              aspectRatio: ratio,\n              borderRadius: radius,\n            }}\n          >\n            <img\n              data-image-loader-asset\n              src={image.src}\n              alt=\"\"\n              loading=\"eager\"\n              decoding=\"async\"\n              className=\"h-full w-full object-cover\"\n              style={{ objectPosition: image.objectPosition ?? \"center\" }}\n            />\n          </div>\n        ))}\n      </div>\n      <div\n        ref={frameRef}\n        aria-hidden={!complete}\n        className=\"invisible absolute top-1/2 left-1/2 z-10 overflow-hidden opacity-0\"\n        style={{\n          width: `min(${width}px, 70%)`,\n          aspectRatio: ratio,\n          borderRadius: radius,\n        }}\n      >\n        <img\n          data-image-loader-asset\n          src={finalImage.src}\n          alt={finalImage.alt ?? \"Revealed image\"}\n          loading=\"eager\"\n          decoding=\"async\"\n          className=\"absolute inset-0 h-full w-full object-cover\"\n          style={{ objectPosition: finalImage.objectPosition ?? \"center\" }}\n        />\n        {imageFailed && (\n          <div\n            role=\"alert\"\n            className=\"absolute inset-0 flex items-center justify-center bg-[#191919] p-6 text-center text-sm text-white/70\"\n          >\n            This image could not be loaded.\n          </div>\n        )}\n      </div>\n      <div\n        ref={contentRef}\n        inert={!complete}\n        aria-hidden={!complete}\n        className=\"invisible absolute inset-0 z-20 opacity-0\"\n      >\n        {children}\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {complete ? \"Images ready\" : label}\n      </span>\n    </div>\n  );\n}\n\nexport default ImageLoader;\n",
      "type": "registry:ui",
      "target": "@ui/image-loader.tsx"
    }
  ]
}
