{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "image-loader",
  "name": "Image Loader",
  "title": "Image Loader",
  "type": "registry:ui",
  "description": "A centered image sequence with smooth crossfades and a final image that expands to fill the frame.",
  "summary": "Preload a sequence of images, cycle them in a compact centered frame, hold the last thumbnail, then expand a separate final image into the full canvas. Customize timing, thumbnail proportions, corner radius, easing, background, and image positioning. Pause, resume, and replay the sequence.",
  "category": "loaders",
  "tags": [
    "loader",
    "images",
    "loading",
    "crossfade",
    "reveal",
    "gsap"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-03",
  "updatedDate": "2026-10-03",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Centered thumbnail cycling with a configurable final hold",
    "A separate final image expands smoothly to the full canvas",
    "Waits for image assets and skips failed thumbnails",
    "Pause and resume through the paused prop, and replay through replayKey",
    "Responsive sizing, scoped animation cleanup, and reduced-motion support"
  ],
  "anatomy": [
    "<ImageLoader> centered loading canvas",
    "Thumbnail stack with coordinated crossfades",
    "Final image frame that expands from thumbnail size",
    "Optional content overlay revealed after expansion"
  ],
  "physics": {
    "engine": "GSAP",
    "description": "A scoped timeline coordinates opacity crossfades and the final centered frame expansion. The frame changes its width and height, following the existing Project Reveal pattern, to maintain natural image cropping as its aspect ratio changes. Resize observation recalculates thumbnail bounds without restarting playback.",
    "parameters": [
      {
        "label": "Image hold",
        "value": "0.22s"
      },
      {
        "label": "Crossfade",
        "value": "0.12s"
      },
      {
        "label": "Final hold",
        "value": "1s"
      },
      {
        "label": "Expansion",
        "value": "0.9s · power3.inOut"
      }
    ]
  },
  "accessibility": {
    "role": "status",
    "aria": "Loading thumbnails are decorative. The final image uses its alt text. Optional content remains inert until completion, and a polite status announces readiness.",
    "reducedMotion": "Waits for assets and reveals the final image immediately, skipping thumbnail cycling and expansion."
  },
  "guidelines": {
    "recommended": [
      "Image-led page introductions",
      "Portfolio and editorial landing page reveals"
    ],
    "bestPractices": [
      "Provide useful alt text for the final image.",
      "Use objectPosition to preserve the subject when the image is cropped.",
      "Use onImageError to report failed URLs and replayKey for another loading cycle.",
      "Keep the sequence short and provide a visible pause control for longer playback."
    ]
  },
  "props": [
    {
      "name": "images",
      "type": "ImageLoaderImage[]",
      "description": "Loading images in playback order. Each entry has src, optional alt and objectPosition.",
      "required": true
    },
    {
      "name": "finalImage",
      "type": "ImageLoaderImage",
      "description": "The separate image that expands to fill the frame.",
      "required": true
    },
    {
      "name": "imageDuration",
      "type": "number",
      "defaultValue": "0.22",
      "description": "Hold between thumbnail crossfades in seconds."
    },
    {
      "name": "transitionDuration",
      "type": "number",
      "defaultValue": "0.12",
      "description": "Thumbnail crossfade duration in seconds."
    },
    {
      "name": "holdDuration",
      "type": "number",
      "defaultValue": "1",
      "description": "Additional hold on the last thumbnail before the final image appears."
    },
    {
      "name": "revealDuration",
      "type": "number",
      "defaultValue": "0.9",
      "description": "Duration of the final image expansion in seconds."
    },
    {
      "name": "initialDelay",
      "type": "number",
      "defaultValue": "0.15",
      "description": "Delay before the first thumbnail appears."
    },
    {
      "name": "thumbnailWidth",
      "type": "number",
      "defaultValue": "240",
      "description": "Thumbnail width in pixels, constrained to fit the container."
    },
    {
      "name": "thumbnailAspectRatio",
      "type": "number",
      "defaultValue": "1.6",
      "description": "Thumbnail width divided by height."
    },
    {
      "name": "borderRadius",
      "type": "number",
      "defaultValue": "6",
      "description": "Thumbnail corner radius in pixels. Settles to zero during expansion."
    },
    {
      "name": "entranceScale",
      "type": "number",
      "defaultValue": "0.85",
      "description": "Initial scale of the first thumbnail."
    },
    {
      "name": "ease",
      "type": "ImageLoaderEase",
      "defaultValue": "\"power3.inOut\"",
      "description": "Expansion easing: power3.inOut, expo.inOut or sine.inOut."
    },
    {
      "name": "background",
      "type": "string",
      "defaultValue": "\"#191919\"",
      "description": "CSS background color behind the thumbnails."
    },
    {
      "name": "paused",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Pause or resume the current timeline without restarting."
    },
    {
      "name": "replayKey",
      "type": "string | number",
      "defaultValue": "0",
      "description": "Change this value to replay from the beginning."
    },
    {
      "name": "label",
      "type": "string",
      "defaultValue": "\"Loading images\"",
      "description": "Accessible loading status."
    },
    {
      "name": "children",
      "type": "ReactNode",
      "description": "Optional content that fades in over the final image."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Container size and styling classes."
    },
    {
      "name": "onComplete",
      "type": "() => void",
      "description": "Called once when the reveal completes."
    },
    {
      "name": "onImageError",
      "type": "(src: string) => void",
      "description": "Reports failed assets. Failed thumbnails are skipped and a final-image error is shown after revealing."
    }
  ],
  "usage": "import { ImageLoader } from \"@/components/ui/image-loader\";\n\nconst images = [\n  { src: \"/images/frame-1.jpg\", alt: \"\" },\n  { src: \"/images/frame-2.jpg\", alt: \"\" },\n];\n\nexport function ImageLoaderDemo() {\n  return (\n    <ImageLoader\n      images={images}\n      finalImage={{ src: \"/images/hero.jpg\", alt: \"Studio workspace\" }}\n      className=\"h-[480px]\"\n    />\n  );\n}",
  "files": [
    {
      "name": "image-loader.tsx",
      "path": "registry/ui/image-loader.tsx",
      "code": "\"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",
      "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"
    }
  ]
}
