{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pixel-image-loader",
  "title": "Pixel Image Loader",
  "description": "A canvas-based image that resolves from a blocky, pixelated placeholder up to full sharpness as it loads.",
  "registryDependencies": ["@soralabs/hooks-use-prefers-reduced-motion"],
  "files": [
    {
      "path": "registry/primitives/effects/pixel-image-loader/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport {\n  type ComponentPropsWithoutRef,\n  type Ref,\n  type RefCallback,\n  useCallback,\n  useEffect,\n  useRef,\n} from \"react\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nconst pixelImageLoaderVariants = cva(\n  \"relative w-full max-w-full overflow-hidden bg-neutral-800\",\n  {\n    variants: {\n      aspect: {\n        square: \"aspect-square\",\n        video: \"aspect-video\",\n        auto: \"\",\n      },\n    },\n    defaultVariants: {\n      aspect: \"square\",\n    },\n  }\n);\n\nconst DEFAULT_STEPS = [2, 5, 6, 8, 100];\nconst AUTO_PLACEHOLDER_WIDTH = 20;\nconst AUTO_PLACEHOLDER_QUALITY = 10;\nconst RESIZE_QUERY_KEYS = [\"w\", \"width\"];\nconst QUALITY_QUERY_KEYS = [\"q\", \"quality\"];\n\n/**\n * Derives a cheap, tiny preview URL from an image already hosted on a\n * resizing CDN (Unsplash, imgix, Cloudinary, Contentful, Shopify, and\n * anything else that exposes width/quality via query params), so a\n * low-res placeholder can be fetched automatically — no hand-crafted\n * base64 needed. Returns `null` when `src` doesn't look resizable (e.g. a\n * static local asset), so callers can fall back to no placeholder.\n */\nfunction getAutoPlaceholderSrc(src: string): string | null {\n  let url: URL;\n  try {\n    url = new URL(src, window.location.origin);\n  } catch {\n    return null;\n  }\n\n  const hostname = url.hostname.toLowerCase();\n  if (\n    (hostname === \"cloudinary.com\" || hostname.endsWith(\".cloudinary.com\")) &&\n    url.pathname.includes(\"/upload/\")\n  ) {\n    return src.replace(\n      \"/upload/\",\n      `/upload/w_${AUTO_PLACEHOLDER_WIDTH},q_${AUTO_PLACEHOLDER_QUALITY},f_auto/`\n    );\n  }\n\n  const hasResizeParam = RESIZE_QUERY_KEYS.some((key) =>\n    url.searchParams.has(key)\n  );\n  if (!hasResizeParam) {\n    return null;\n  }\n\n  for (const key of RESIZE_QUERY_KEYS) {\n    if (url.searchParams.has(key)) {\n      url.searchParams.set(key, String(AUTO_PLACEHOLDER_WIDTH));\n    }\n  }\n  for (const key of QUALITY_QUERY_KEYS) {\n    if (url.searchParams.has(key)) {\n      url.searchParams.set(key, String(AUTO_PLACEHOLDER_QUALITY));\n    }\n  }\n\n  return url.toString();\n}\n\n/** Source rect that crops the image to the box's aspect ratio, mirroring `object-fit: cover`. */\nfunction getCoverSourceRect(\n  imgWidth: number,\n  imgHeight: number,\n  boxWidth: number,\n  boxHeight: number\n) {\n  const imgRatio = imgWidth / imgHeight;\n  const boxRatio = boxWidth / boxHeight;\n\n  if (imgRatio > boxRatio) {\n    const sw = imgHeight * boxRatio;\n    return { sx: (imgWidth - sw) / 2, sy: 0, sw, sh: imgHeight };\n  }\n\n  const sh = imgWidth / boxRatio;\n  return { sx: 0, sy: (imgHeight - sh) / 2, sw: imgWidth, sh };\n}\n\nfunction mergeRefs<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> {\n  return (node) => {\n    for (const ref of refs) {\n      if (!ref) {\n        continue;\n      }\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else {\n        ref.current = node;\n      }\n    }\n  };\n}\n\nexport interface PixelImageLoaderProps\n  extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\">,\n    VariantProps<typeof pixelImageLoaderVariants> {\n  /** Accessible label for the image. */\n  alt: string;\n  /**\n   * When `placeholder` isn't set, tries to derive a cheap low-res preview\n   * from `src` itself for CDNs that expose resizing via query params or\n   * path segments (Unsplash, imgix, Cloudinary, Contentful, Shopify, and\n   * similar). Has no effect on static assets that aren't resizable this\n   * way — set `placeholder` explicitly for those.\n   * @default true\n   */\n  autoPlaceholder?: boolean;\n  /**\n   * Delay before the first pixelation step fires, in ms.\n   * @default 300\n   */\n  initialDelay?: number;\n  /**\n   * A tiny low-resolution image (typically a base64 data URI) drawn,\n   * pixelated, the instant this mounts — no network round trip needed.\n   * Replaces the empty background while `src` is still loading, so slow\n   * connections show a coarse preview of the image instead of a blank box.\n   * Once `src` finishes loading, the reveal continues from this frame.\n   * Takes priority over `autoPlaceholder`.\n   */\n  placeholder?: string;\n  ref?: Ref<HTMLDivElement>;\n  /** Image source. */\n  src: string;\n  /**\n   * Delay between each step after the first, in ms.\n   * @default 80\n   */\n  stepDuration?: number;\n  /**\n   * Resolution steps (1-100, percent of full resolution) the reveal walks\n   * through before landing on the sharp image. Ending on 100 is required to\n   * land on a fully sharp frame.\n   * @default [2, 5, 6, 8, 100]\n   */\n  steps?: number[];\n  /**\n   * \"inView\" starts the reveal once the element scrolls into the viewport.\n   * \"immediate\" starts as soon as the image has loaded.\n   * @default \"inView\"\n   */\n  trigger?: \"immediate\" | \"inView\";\n}\n\nfunction PixelImageLoader({\n  aspect = \"square\",\n  src,\n  alt,\n  autoPlaceholder = true,\n  placeholder,\n  steps = DEFAULT_STEPS,\n  initialDelay = 300,\n  stepDuration = 80,\n  trigger = \"inView\",\n  className,\n  ref,\n  ...props\n}: PixelImageLoaderProps) {\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tinyCanvasRef = useRef<HTMLCanvasElement | null>(null);\n  const imgRef = useRef<HTMLImageElement | null>(null);\n  const placeholderImgRef = useRef<HTMLImageElement | null>(null);\n  const activeImgRef = useRef<HTMLImageElement | null>(null);\n  const lastStepRef = useRef(0);\n  const timeoutIdsRef = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const hasStartedRef = useRef(false);\n  const isInViewRef = useRef(false);\n  const isLoadedRef = useRef(false);\n\n  const clearScheduledTimeouts = useCallback(() => {\n    for (const timeoutId of timeoutIdsRef.current) {\n      clearTimeout(timeoutId);\n    }\n    timeoutIdsRef.current = [];\n  }, []);\n\n  const drawStep = useCallback((percent: number) => {\n    const container = containerRef.current;\n    const canvas = canvasRef.current;\n    const img = activeImgRef.current;\n    if (!(container && canvas && img)) {\n      return;\n    }\n\n    lastStepRef.current = percent;\n\n    const rect = container.getBoundingClientRect();\n    const width = Math.max(1, Math.floor(rect.width));\n    const height = Math.max(1, Math.floor(rect.height));\n    const dpr = window.devicePixelRatio || 1;\n\n    if (canvas.width !== width * dpr || canvas.height !== height * dpr) {\n      canvas.width = width * dpr;\n      canvas.height = height * dpr;\n      canvas.style.width = `${width}px`;\n      canvas.style.height = `${height}px`;\n    }\n\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) {\n      return;\n    }\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    ctx.clearRect(0, 0, width, height);\n\n    const { sx, sy, sw, sh } = getCoverSourceRect(\n      img.naturalWidth,\n      img.naturalHeight,\n      width,\n      height\n    );\n\n    if (percent >= 100) {\n      ctx.imageSmoothingEnabled = true;\n      ctx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);\n      return;\n    }\n\n    const scale = Math.max(percent / 100, 0.02);\n    const downWidth = Math.max(1, Math.round(width * scale));\n    const downHeight = Math.max(1, Math.round(height * scale));\n\n    if (!tinyCanvasRef.current) {\n      tinyCanvasRef.current = document.createElement(\"canvas\");\n    }\n    const tiny = tinyCanvasRef.current;\n    tiny.width = downWidth;\n    tiny.height = downHeight;\n\n    const tinyCtx = tiny.getContext(\"2d\");\n    if (!tinyCtx) {\n      return;\n    }\n    tinyCtx.imageSmoothingEnabled = true;\n    tinyCtx.drawImage(img, sx, sy, sw, sh, 0, 0, downWidth, downHeight);\n\n    ctx.imageSmoothingEnabled = false;\n    ctx.drawImage(tiny, 0, 0, downWidth, downHeight, 0, 0, width, height);\n  }, []);\n\n  const runReveal = useCallback(() => {\n    if (hasStartedRef.current) {\n      return;\n    }\n    hasStartedRef.current = true;\n    clearScheduledTimeouts();\n    activeImgRef.current = imgRef.current;\n\n    if (prefersReducedMotion) {\n      drawStep(100);\n      return;\n    }\n\n    const orderedSteps = steps.at(-1) === 100 ? steps : [...steps, 100];\n\n    orderedSteps.forEach((percent, index) => {\n      const delay = initialDelay + index * stepDuration;\n      const timeoutId = setTimeout(() => drawStep(percent), delay);\n      timeoutIdsRef.current.push(timeoutId);\n    });\n  }, [\n    clearScheduledTimeouts,\n    drawStep,\n    initialDelay,\n    prefersReducedMotion,\n    stepDuration,\n    steps,\n  ]);\n\n  const maybeStart = useCallback(() => {\n    if (!isLoadedRef.current) {\n      return;\n    }\n    if (trigger === \"inView\" && !isInViewRef.current) {\n      return;\n    }\n    runReveal();\n  }, [runReveal, trigger]);\n\n  useEffect(() => {\n    hasStartedRef.current = false;\n    isLoadedRef.current = false;\n    clearScheduledTimeouts();\n\n    const image = new Image();\n    imgRef.current = image;\n    image.onload = () => {\n      isLoadedRef.current = true;\n      maybeStart();\n    };\n    image.src = src;\n\n    return () => {\n      image.onload = null;\n    };\n  }, [src, clearScheduledTimeouts, maybeStart]);\n\n  useEffect(() => {\n    const placeholderSrc =\n      placeholder ?? (autoPlaceholder ? getAutoPlaceholderSrc(src) : null);\n    if (!placeholderSrc) {\n      return;\n    }\n\n    const image = new Image();\n    placeholderImgRef.current = image;\n    image.onload = () => {\n      if (hasStartedRef.current) {\n        return;\n      }\n      activeImgRef.current = image;\n      drawStep(steps[0] ?? 100);\n    };\n    image.src = placeholderSrc;\n\n    return () => {\n      image.onload = null;\n    };\n  }, [placeholder, autoPlaceholder, src, steps, drawStep]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    if (trigger !== \"inView\") {\n      maybeStart();\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          isInViewRef.current = true;\n          maybeStart();\n          observer.disconnect();\n        }\n      },\n      { threshold: 0.15 }\n    );\n    observer.observe(container);\n\n    return () => {\n      observer.disconnect();\n    };\n  }, [trigger, maybeStart]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    const resizeObserver = new ResizeObserver(() => {\n      if (activeImgRef.current) {\n        drawStep(lastStepRef.current || 100);\n      }\n    });\n    resizeObserver.observe(container);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, [drawStep]);\n\n  useEffect(() => clearScheduledTimeouts, [clearScheduledTimeouts]);\n\n  return (\n    <div\n      className={cn(pixelImageLoaderVariants({ aspect, className }))}\n      ref={mergeRefs(containerRef, ref)}\n      {...props}\n    >\n      <canvas\n        aria-label={alt}\n        className=\"absolute inset-0 h-full w-full\"\n        ref={canvasRef}\n        role=\"img\"\n      />\n    </div>\n  );\n}\n\nexport { PixelImageLoader, pixelImageLoaderVariants };\n",
      "type": "registry:ui",
      "target": "components/sora-ui/effects/pixel-image-loader.tsx"
    }
  ],
  "meta": {
    "demoProps": {
      "PixelImageLoader": {
        "src": {
          "value": "https://images.unsplash.com/photo-1526779259212-939e64788e3c?q=80&w=764&auto=format&fit=crop"
        },
        "alt": {
          "value": "Pixel image loader demo"
        },
        "aspect": {
          "value": "square",
          "options": {
            "Square": "square",
            "Video": "video",
            "Auto": "auto"
          }
        },
        "initialDelay": {
          "value": 0,
          "min": 0,
          "max": 1000,
          "step": 50
        },
        "stepDuration": {
          "value": 80,
          "min": 20,
          "max": 300,
          "step": 10
        },
        "trigger": {
          "value": "inView",
          "options": {
            "In View": "inView",
            "Immediate": "immediate"
          }
        },
        "className": {
          "value": "mx-auto w-72 rounded-lg"
        }
      }
    }
  },
  "type": "registry:ui"
}
