{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blur-up-image",
  "type": "registry:ui",
  "title": "Blur-up Image",
  "description": "Holds its space, then brings the full image into focus over a blurred preview.",
  "categories": [
    "media"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/blur-up-image.tsx",
      "content": "\"use client\";\nimport { useCallback, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\nexport type ImageStatus = \"loading\" | \"loaded\" | \"error\";\n\nconst noop = () => () => {};\n\n/**\n * The load lifecycle of one image, without the markup. Spread `imgProps` on an <img>.\n * `instant` is true when the image was already decoded the moment it mounted on the\n * client (the browser cache), so a caller can skip its reveal instead of replaying it.\n * Give the <img> the returned `key` so a retry or a new src gets a fresh element.\n */\nexport function useImageLoad(src: string | undefined, { onLoad, onError }: { onLoad?: () => void; onError?: () => void } = {}) {\n  const [attempt, setAttempt] = useState(0);\n  const key = `${src ?? \"\"}#${attempt}`;\n  const [result, setResult] = useState<{ key: string; status: ImageStatus; instant: boolean } | null>(null);\n  // During hydration the server snapshot (false) is used, so an image that finished before\n  // React arrived still gets its reveal: the visitor was looking at the placeholder until now.\n  const hydrated = useSyncExternalStore(noop, () => true, () => false);\n\n  const settledKey = useRef<string>(null);\n\n  const current = result?.key === key ? result : null;\n  const status: ImageStatus = src === undefined ? \"loading\" : (current?.status ?? \"loading\");\n\n  const settle = useCallback(\n    (img: HTMLImageElement, instant: boolean) => {\n      if (settledKey.current === key) return;\n      settledKey.current = key;\n      // Wait for decode so the reveal never starts on a half-painted frame.\n      const done = () => {\n        setResult({ key, status: \"loaded\", instant });\n        onLoad?.();\n      };\n      if (instant || !img.decode) done();\n      else img.decode().then(done, done);\n    },\n    [key, onLoad],\n  );\n\n  // Catches images that finished before React attached its listeners (cache, or before hydration).\n  const ref = useCallback(\n    (img: HTMLImageElement | null) => {\n      if (!img || !src || !img.complete) return;\n      if (img.naturalWidth > 0) settle(img, hydrated);\n      else if (img.currentSrc) setResult({ key, status: \"error\", instant: false });\n    },\n    [key, src, settle, hydrated],\n  );\n\n  return {\n    status,\n    instant: !!current?.instant,\n    attempt,\n    retry: () => setAttempt((a) => a + 1),\n    key,\n    imgProps: {\n      ref,\n      onLoad: (e: React.SyntheticEvent<HTMLImageElement>) => settle(e.currentTarget, false),\n      onError: () => {\n        settledKey.current = null;\n        setResult({ key, status: \"error\", instant: false });\n        onError?.();\n      },\n    },\n  };\n}\n\nexport type BlurUpImageProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onLoad\" | \"onError\"> & {\n  /** Leave undefined while the URL is still being resolved; the placeholder holds the space. */\n  src?: string;\n  alt: string;\n  srcSet?: string;\n  sizes?: string;\n  /** Intrinsic size. Either this pair or `aspectRatio` reserves the box before any bytes arrive. */\n  width?: number;\n  height?: number;\n  aspectRatio?: number | string;\n  /** A tiny version of the image (a 16–32px URL or a data URI), shown blurred until the real one lands. */\n  placeholder?: string;\n  /** The image's dominant color, painted behind everything. Any CSS color. */\n  color?: string;\n  fit?: \"cover\" | \"contain\";\n  /** Load eagerly at high priority. Use for the one image above the fold that matters. */\n  priority?: boolean;\n  /** Replaces the default failed state. */\n  fallback?: React.ReactNode;\n  errorLabel?: string;\n  retryLabel?: string;\n  onLoad?: () => void;\n  onError?: () => void;\n  /** Called when the person presses the retry button, before the image is requested again. */\n  onRetry?: () => void;\n  imgClassName?: string;\n};\n\nexport function BlurUpImage({\n  src,\n  alt,\n  srcSet,\n  sizes,\n  width,\n  height,\n  aspectRatio,\n  placeholder,\n  color,\n  fit = \"cover\",\n  priority = false,\n  fallback,\n  errorLabel = \"Couldn’t load image\",\n  retryLabel = \"Try again\",\n  onLoad,\n  onError,\n  onRetry,\n  className,\n  imgClassName,\n  style,\n  ref,\n  ...rest\n}: BlurUpImageProps) {\n  const { status, instant, key, imgProps, retry, attempt } = useImageLoad(src, { onLoad, onError });\n  const rootRef = useRef<HTMLDivElement>(null);\n  const ratio = aspectRatio ?? (width && height ? `${width} / ${height}` : undefined);\n  const settled = status === \"loaded\";\n\n  return (\n    <div\n      ref={(node) => {\n        rootRef.current = node;\n        if (typeof ref === \"function\") return ref(node);\n        if (ref) ref.current = node;\n      }}\n      // Focus lands here when the retry button it held disappears, instead of dropping to the page.\n      tabIndex={-1}\n      data-state={status}\n      data-fit={fit}\n      aria-busy={status === \"loading\" || undefined}\n      className={cn(\"@container relative isolate overflow-hidden bg-hover outline-none\", className)}\n      style={{ aspectRatio: ratio, backgroundColor: color, ...style }}\n      {...rest}\n    >\n      {/* Nothing to show yet and nothing known about the image: a slow, low pulse, only after 400ms. */}\n      {!placeholder && !color && (\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute inset-0 bg-fg/[0.025] transition-opacity duration-300\",\n            status === \"loading\" ? \"animate-pulse-soft [animation-delay:400ms] motion-reduce:animate-none\" : \"opacity-0\",\n          )}\n        />\n      )}\n\n      {placeholder && (\n        // Scaled past the edges so the blur has pixels to pull from and never shows a soft border.\n        // eslint-disable-next-line @next/next/no-img-element\n        <img\n          aria-hidden\n          alt=\"\"\n          src={placeholder}\n          decoding=\"async\"\n          className={cn(\n            \"absolute inset-0 size-full scale-110 object-cover blur-xl\",\n            \"transition-opacity duration-300 ease-out\",\n            // Leaves only once the real image is fully in, so the two never show a gap between them.\n            settled && !instant ? \"opacity-0 delay-500\" : settled ? \"opacity-0 duration-0\" : \"opacity-100\",\n          )}\n        />\n      )}\n\n      {src !== undefined && (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img\n          key={key}\n          {...imgProps}\n          src={src}\n          srcSet={srcSet}\n          sizes={sizes}\n          alt={alt}\n          width={width}\n          height={height}\n          loading={priority ? \"eager\" : \"lazy\"}\n          fetchPriority={priority ? \"high\" : undefined}\n          decoding=\"async\"\n          draggable={false}\n          data-instant={instant || undefined}\n          className={cn(\n            \"absolute inset-0 size-full\",\n            fit === \"cover\" ? \"object-cover\" : \"object-contain\",\n            // The reveal: fades in while it sharpens and settles, so it reads as the placeholder coming into focus.\n            \"transition-[opacity,filter,scale] ease-out-quart motion-reduce:transition-opacity\",\n            \"[transition-duration:450ms,650ms,700ms] motion-reduce:duration-200\",\n            settled ? \"scale-100 opacity-100 blur-[0px]\" : \"scale-[1.03] opacity-0 blur-md motion-reduce:scale-100 motion-reduce:blur-none\",\n            \"data-instant:transition-none\",\n            imgClassName,\n          )}\n        />\n      )}\n\n      {status === \"error\" &&\n        (fallback ?? (\n          <div className=\"absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-[inherit] border border-line bg-raised p-3 text-center transition-opacity duration-200 ease-out starting:opacity-0\">\n            <BrokenImage className=\"text-fg-4\" />\n            <p className=\"hidden text-[12px] leading-4 text-fg-3 @[140px]:block\">{errorLabel}</p>\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                if (e.currentTarget === document.activeElement) rootRef.current?.focus({ preventScroll: true });\n                onRetry?.();\n                retry();\n              }}\n              aria-label={`${retryLabel}: ${alt}`}\n              className={cn(\n                \"group/retry relative inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-raised px-2 text-[12px] font-medium text-fg shadow-[var(--shadow)]\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.96] active:duration-75\",\n                \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            >\n              <svg\n                width=\"14\"\n                height=\"14\"\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={1.4}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                aria-hidden\n                // Each attempt turns the arrow once more, so a second press visibly does something.\n                style={{ rotate: `${attempt * 360}deg` }}\n                className=\"text-fg-2 transition-[rotate] duration-500 ease-out-expo motion-reduce:transition-none\"\n              >\n                <path d=\"M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25\" />\n              </svg>\n              <span className=\"hidden @[110px]:inline\">{retryLabel}</span>\n            </button>\n          </div>\n        ))}\n    </div>\n  );\n}\n\nfunction BrokenImage({ className }: { className?: string }) {\n  return (\n    <svg width=\"20\" height=\"20\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={className}>\n      <path d=\"M13.5 9.5V4.75A1.75 1.75 0 0 0 11.75 3h-6.5M2.5 5.25v6A1.75 1.75 0 0 0 4.25 13h7\" />\n      <path d=\"m2.75 11.25 3.4-3 2.4 2M2.5 2.5l11 11\" />\n      <circle cx=\"10\" cy=\"6.25\" r=\"1\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/blur-up-image"
}
