{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lazy-image",
  "title": "Lazy Image",
  "description": "An <img> wrapper that holds its place in the layout from the first frame and defers its own fetch, for a page with more pictures than the screen: an article body, a product or portfolio grid, a long feed or timeline, a gallery or lightbox thumbnail strip, a list of avatars or logos, a comparison table of screenshots, a chat history with attachments, a blog index with hero thumbnails. Two things it gets right that a hand-written <img loading=\"lazy\"> does not. First, the space: width and height attributes alone compute to aspect-ratio: auto W / H, and auto means the box re-shapes to the real file once it is known -- so a wrong declared size still shifts the page, just later (measured: a box declared 120x90 in a 300px column reserved 300x225 and then jumped to 300x300 when a 64x64 file landed). This emits the explicit aspect-ratio, which held at 300x225 through that same mismatch, and pairs it with object-fit so the picture is cropped rather than stretched into it. Second, the status: onLoad is not enough, because an image the browser had already finished with fires nothing for a handler attached later -- which is exactly hydration and a second visit to a cached URL, so the placeholder sticks on precisely the fastest images. It asks the element instead, and reads naturalWidth rather than complete, because complete is also true after a 404 and true for an element with no src at all. The fallback src is tried once and never retried, so a missing fallback cannot become the onError loop that hangs the tab. Common asks it answers: \"lazy load images react\", \"react lazy image component\", \"shadcn image component\", \"shadcn lazy image\", \"next/image without next\", \"next image alternative\", \"image placeholder while loading react\", \"blurDataURL without next.js\", \"lqip react\", \"blur up image react\", \"image skeleton loader\", \"prevent layout shift images\", \"fix CLS images\", \"cumulative layout shift image react\", \"reserve space for image before it loads\", \"aspect ratio image placeholder\", \"image jumps when it loads\", \"page jumps as images load\", \"img onLoad not firing\", \"onLoad never fires for cached image\", \"image placeholder stuck on refresh\", \"react image onLoad hydration\", \"detect broken image react\", \"image fallback on error react\", \"onError infinite loop img\", \"fallback image react\", \"broken image placeholder\", \"img loading lazy react\", \"intersection observer image loading\", \"defer offscreen images\", \"lighthouse defer offscreen images\", \"lcp image fetchpriority\", \"eager load above the fold image\", \"aria-busy image\", \"decoding async img react\", \"lazy load below the fold\", \"image placeholder tailwind\", \"responsive image aspect ratio tailwind\", \"gallery thumbnails lazy load\", \"avatar list lazy load\", \"画像 遅延読み込み react\", \"画像 レイアウトシフト 防ぐ\", \"CLS 画像 対策\", \"画像 プレースホルダー react\", \"読み込み中 画像 スケルトン\", \"next/image を使わず遅延読み込み\", \"キャッシュ済み画像で onLoad が発火しない\". Not the same thing as official shadcn's aspect-ratio, and not a replacement for it, and the gap is measurable rather than a matter of emphasis: that component is 274 bytes today -- a bare re-export of Radix's AspectRatio.Root with a data-slot on it -- and its skeleton is 267 bytes of div with animate-pulse. Neither knows anything about loading, caching or failure, and across all 223,287 bytes of official's registry (65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks) the strings lazy, naturalWidth, onLoad, onError, IntersectionObserver and fetchpriority are every one of them a zero hit. Official ships the box; nothing there watches the picture arrive. This is the image itself, and needs no wrapper element -- the reserved ratio, the skeleton and the low-resolution preview are all the img's own style, so it drops into the markup the old <img> was in and keeps every selector and grid placement that pointed at it. What it deliberately leaves alone: srcset and sizes (pass them through; picking them is the job of whatever produces your images), generating the tiny preview, and any CDN. Within pulld, image-zoom takes over once a picture is on screen and worth examining, image-comparison puts two side by side, and avatar-stack handles the small round case. useLazyImage is exported for an <img> of your own. Depends only on your cn util -- no packages, no other components.",
  "files": [
    {
      "path": "registry/ui/lazy-image.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** What the image is doing right now. */\nexport type LazyImageStatus = \"loading\" | \"loaded\" | \"error\"\n\n/**\n * The shape this reads off a real `<img>`. Declared as the two properties rather than as\n * `HTMLImageElement` so the decision below can be tested with an object literal.\n */\nexport interface ImageReadout {\n  complete?: boolean\n  naturalWidth?: number\n}\n\n/**\n * What an element is saying about its image, or `null` for \"nothing yet\".\n *\n * This exists because `onLoad` alone is not enough, and the usual framing of why is wrong. The\n * claim is normally that a cached image never fires `load`; measured in Chromium, a listener\n * attached in the same tick *after* `src` is assigned still receives it, which is what React does\n * when it renders the element itself. The case that genuinely misses the event is a listener\n * attached a macrotask or more late — a 60ms delay was enough to miss it here — and that is exactly\n * what hydration is: the server sent `<img src>`, the browser fetched and decoded it while the\n * bundle was still parsing, and React attaches `onLoad` to an element that finished long ago. The\n * placeholder then stays forever on precisely the images that were fastest.\n *\n * So the element is asked directly, and `complete` on its own is not the question either:\n *\n *   - `complete` is also true after a failed load (measured: a 404 leaves `complete === true` and\n *     `naturalWidth === 0`), so reading it alone reports a broken image as a loaded one;\n *   - `complete` is true for an element with no `src` at all, and for one with `src=\"\"`, both with\n *     `naturalWidth === 0` — which is why `src` has to be part of this decision rather than\n *     something the caller is trusted to have checked. Without that guard every image renders\n *     \"error\" for one commit before it is even asked to fetch anything.\n *\n * `naturalWidth` is therefore the signal and `complete` only says whether the answer is in yet.\n */\nexport function readImageStatus(\n  node: ImageReadout | null | undefined,\n  src: string | undefined\n): \"loaded\" | \"error\" | null {\n  if (!node || !src) return null\n  if (!node.complete) return null\n  return (node.naturalWidth ?? 0) > 0 ? \"loaded\" : \"error\"\n}\n\n/**\n * The source to try after `current` failed, or `null` when there is nothing left to try.\n *\n * The one-line version of a fallback — `onError={() => setSrc(fallback)}` — is the known way to\n * hang a page, and the reason is that the error does not stop coming: measured, swapping `src` from\n * one 404 to another fires `error` a second time, so a handler that answers every error by\n * assigning the fallback is a loop as soon as the fallback is itself missing. Comparing against\n * `current` makes the swap one-way; the second failure finds nothing left and settles on \"error\".\n */\nexport function nextSourceAfterError(\n  current: string | undefined,\n  fallbackSrc: string | undefined\n): string | null {\n  if (!fallbackSrc) return null\n  if (current === fallbackSrc) return null\n  return fallbackSrc\n}\n\n/**\n * The `aspect-ratio` to hold the box at, as a CSS value, or `undefined` when nothing was declared.\n *\n * Which form of ratio is used is the whole difference between reserving space and only appearing\n * to. Measured in a 300px column, with `width: 100%; height: auto`:\n *\n *   - no `width`/`height` attributes: the box is 300x0 until the bytes arrive and 300x225 after —\n *     a 225px shift of everything below it, which is the ordinary CLS complaint;\n *   - `width=\"120\" height=\"90\"` attributes: 300x225 from the first frame, before `src` is even\n *     set. The attributes work. But the style they compute to is `aspect-ratio: auto 120 / 90`,\n *     and `auto` means \"prefer the real ratio once it is known\" — so when the declared ratio is\n *     wrong, the box still moves, just later: declaring 120x90 for a file that turned out to be\n *     64x64 reshaped the box from 300x225 to 300x300 on arrival;\n *   - an explicit `aspect-ratio: 4 / 3` in the style: 300x225 before the fetch and 300x225 after\n *     that same 1:1 file landed. The box does not move at all.\n *\n * So this emits the explicit form. The cost is that a mismatched image is then drawn into a box of\n * the wrong shape, and the default `object-fit: fill` stretches it — which is the grain of truth in\n * \"applying aspect-ratio distorts the picture\". It distorts only because nothing said how to fit;\n * `fit` below answers that, and the pair together is the only combination measured to shift\n * nothing and stretch nothing.\n */\nexport function reserveRatio(\n  width: number | string | undefined,\n  height: number | string | undefined,\n  ratio: number | string | undefined\n): string | undefined {\n  if (ratio !== undefined) return typeof ratio === \"number\" ? `${ratio}` : ratio\n  const w = typeof width === \"string\" ? Number(width) : width\n  const h = typeof height === \"string\" ? Number(height) : height\n  if (!w || !h || !Number.isFinite(w) || !Number.isFinite(h)) return undefined\n  return `${w} / ${h}`\n}\n\nexport interface UseLazyImageOptions {\n  /** The image to show. Changing it starts over: back to `loading`, and the fallback is rearmed. */\n  src?: string\n  /** Shown instead if `src` fails. Tried once, never retried. */\n  fallbackSrc?: string\n  /** Called whenever the status changes, including the first time it settles. */\n  onStatusChange?: (status: LazyImageStatus) => void\n}\n\nexport interface LazyImageLoad {\n  status: LazyImageStatus\n  /** The source to render right now — `src`, or `fallbackSrc` once `src` has failed. */\n  currentSrc: string | undefined\n  /** Attach to the `<img>`. The status is read off the element, not only from its events. */\n  ref: React.RefObject<HTMLImageElement | null>\n  onLoad: React.ReactEventHandler<HTMLImageElement>\n  onError: React.ReactEventHandler<HTMLImageElement>\n}\n\n// useLayoutEffect reads the element before paint, so an image that was already complete at mount\n// never shows a frame of placeholder — but it warns during SSR, where there is no element to read.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n/**\n * The loading lifecycle on its own, for an `<img>` you would rather write yourself.\n *\n * Everything that makes this more than a `useState` is in `readImageStatus` and\n * `nextSourceAfterError` above; this wires them to an element and to prop changes.\n */\nexport function useLazyImage({\n  src,\n  fallbackSrc,\n  onStatusChange,\n}: UseLazyImageOptions = {}): LazyImageLoad {\n  const ref = React.useRef<HTMLImageElement | null>(null)\n\n  // `source` is the one being attempted; `startedFrom` remembers which `src` prop that attempt\n  // belongs to. Comparing them during render is how a new `src` resets everything without an\n  // effect — an effect would leave one commit showing the old image's \"loaded\" state under the new\n  // URL, which is the flash of the previous picture that a gallery shows when you page through it.\n  const [state, setState] = React.useState<{\n    startedFrom: string | undefined\n    source: string | undefined\n    status: LazyImageStatus\n  }>({ startedFrom: src, source: src, status: \"loading\" })\n\n  let current = state\n  if (state.startedFrom !== src) {\n    current = { startedFrom: src, source: src, status: \"loading\" }\n    setState(current)\n  }\n\n  // Held in a ref so changing the callback does not re-run the effect below, and so the effect\n  // does not need it in its dependencies to call the current one.\n  const onStatusChangeRef = React.useRef(onStatusChange)\n  useIsomorphicLayoutEffect(() => {\n    onStatusChangeRef.current = onStatusChange\n  }, [onStatusChange])\n\n  const settle = React.useCallback((status: LazyImageStatus, source: string | undefined) => {\n    setState((previous) => {\n      if (previous.source !== source || previous.status === status) return previous\n      return { ...previous, status }\n    })\n  }, [])\n\n  const fail = React.useCallback(\n    (source: string | undefined) => {\n      setState((previous) => {\n        if (previous.source !== source) return previous\n        const next = nextSourceAfterError(previous.source, fallbackSrc)\n        // Returning the same object rather than an equal one matters: a broken element keeps\n        // reporting `naturalWidth === 0`, so this is asked again on every commit, and a fresh\n        // object each time is a fresh render each time. React bails out on identity alone.\n        if (next === null) {\n          return previous.status === \"error\" ? previous : { ...previous, status: \"error\" }\n        }\n        return { ...previous, source: next, status: \"loading\" }\n      })\n    },\n    [fallbackSrc]\n  )\n\n  // The element is asked on every commit, not only on mount: a `src` change puts the status back to\n  // `loading`, and the new URL may be in the list of available images already, in which case there\n  // is no event coming for it either (same reason as hydration — the answer is there before anyone\n  // asks). Reading it here settles both cases through one path.\n  useIsomorphicLayoutEffect(() => {\n    const answer = readImageStatus(ref.current, current.source)\n    if (answer === \"loaded\") settle(\"loaded\", current.source)\n    else if (answer === \"error\") fail(current.source)\n  }, [current.source, current.status, settle, fail])\n\n  const previousStatus = React.useRef<LazyImageStatus | null>(null)\n  useIsomorphicLayoutEffect(() => {\n    if (previousStatus.current === current.status) return\n    previousStatus.current = current.status\n    onStatusChangeRef.current?.(current.status)\n  }, [current.status])\n\n  const onLoad = React.useCallback(\n    (event: React.SyntheticEvent<HTMLImageElement>) => {\n      // Trust the element over the event: `load` also fires for an image that decoded to nothing in\n      // some error paths, and `naturalWidth` is the only thing that separates the two.\n      const answer = readImageStatus(event.currentTarget, current.source)\n      if (answer === \"error\") fail(current.source)\n      else settle(\"loaded\", current.source)\n    },\n    [current.source, settle, fail]\n  )\n\n  const onError = React.useCallback(() => fail(current.source), [current.source, fail])\n\n  return { status: current.status, currentSrc: current.source, ref, onLoad, onError }\n}\n\nexport interface LazyImageProps\n  extends Omit<React.ComponentPropsWithoutRef<\"img\">, \"onLoad\" | \"onError\" | \"alt\">,\n    UseLazyImageOptions {\n  /**\n   * Required rather than optional, unlike on a bare `<img>`. Pass `alt=\"\"` for decoration — that\n   * is a decision, and an empty string records it; a missing attribute only records that nobody\n   * thought about it, and a screen reader then reads the URL aloud.\n   */\n  alt: string\n  /**\n   * How the picture fills the box that was reserved for it. `cover` by default because the box is\n   * pinned to the declared ratio (see `reserveRatio`), so anything whose real ratio differs would\n   * otherwise be stretched to fit.\n   */\n  fit?: \"cover\" | \"contain\" | \"fill\" | \"none\" | \"scale-down\"\n  /**\n   * An explicit ratio for the reserved box — `16 / 9`, `\"4 / 3\"`, or a number. Only needed when\n   * `width` and `height` are not given, or when the box should not match the file.\n   */\n  ratio?: number | string\n  /**\n   * A tiny inline image (a data URI, a few hundred bytes) drawn in the reserved box while the real\n   * one is in flight. The low-resolution original is what makes it look soft; no filter is applied,\n   * so nothing has to be undone once the real image is on top of it.\n   */\n  placeholder?: string\n  /**\n   * Load immediately instead of when it comes near the viewport. Set it on the image that is\n   * already on screen when the page opens: deferring that one costs a round trip in the middle of\n   * the largest paint, and native laziness really does defer — measured 5,000px below the fold, the\n   * element was still `complete === false` after 1.5s with no scrolling.\n   */\n  eager?: boolean\n}\n\n/**\n * An `<img>` that holds its place in the layout from the first frame and defers its own fetch,\n * so a long page of pictures neither pushes itself around as they arrive nor fetches all of them\n * at once.\n *\n * It stays one element. There is no wrapper, no `position: relative` parent, and no second node for\n * the placeholder — the reserved ratio, the skeleton and the low-resolution preview are all the\n * image's own style, which is what keeps it a drop-in for the `<img>` it replaces: every selector,\n * grid placement and margin that already pointed at that element still points at it.\n *\n * What it does not do: pick a `srcset` for you, host or generate the low-resolution preview, or\n * decode off the main thread. Those belong to whatever produces the images. Within pulld,\n * `image-zoom` takes over once an image is on screen and worth examining, `image-comparison` puts\n * two of them side by side, and `avatar-stack` handles the small round case with its own fallbacks.\n *\n * Nothing here needs official shadcn's `aspect-ratio`, and the two are not alternatives: that one\n * is a Radix box that imposes a ratio on whatever is inside it, and knows nothing about loading,\n * caching or failure. This is the image itself.\n */\nexport function LazyImage({\n  src,\n  fallbackSrc,\n  onStatusChange,\n  alt,\n  fit = \"cover\",\n  ratio,\n  placeholder,\n  eager = false,\n  width,\n  height,\n  className,\n  style,\n  ...props\n}: LazyImageProps) {\n  const { status, currentSrc, ref, onLoad, onError } = useLazyImage({\n    src,\n    fallbackSrc,\n    onStatusChange,\n  })\n  const aspectRatio = reserveRatio(width, height, ratio)\n  const loading = status === \"loading\"\n\n  return (\n    <img\n      ref={ref}\n      src={currentSrc}\n      alt={alt}\n      width={width}\n      height={height}\n      // Set as attributes rather than toggled later: both are read when the element is inserted,\n      // and an element that has already begun loading cannot be made lazy afterwards.\n      loading={eager ? \"eager\" : \"lazy\"}\n      // `async` only for the deferred ones, so a decode cannot hold up a frame the reader is\n      // already looking at. The eager one is left at the browser's own default: `sync` would be the\n      // tempting symmetry, and it can delay the first paint it is meant to improve, which is not\n      // something measured here and so not something to assert in a default.\n      decoding={eager ? undefined : \"async\"}\n      fetchPriority={eager ? \"high\" : undefined}\n      // Announced as busy only while something is genuinely on its way. A broken image is not busy;\n      // it is an image whose `alt` is now the whole of it, which is what a browser shows anyway.\n      aria-busy={loading || undefined}\n      onLoad={onLoad}\n      onError={onError}\n      className={cn(\n        \"block max-w-full\",\n        fit === \"cover\" && \"object-cover\",\n        fit === \"contain\" && \"object-contain\",\n        fit === \"fill\" && \"object-fill\",\n        fit === \"none\" && \"object-none\",\n        fit === \"scale-down\" && \"object-scale-down\",\n        // The skeleton is the element's own background, so it is covered by the picture the moment\n        // there is one and needs no removal — except that a transparent PNG would keep showing it,\n        // which is why it is dropped on `loaded` as well as never drawn under a preview.\n        loading && !placeholder && \"animate-pulse bg-muted\",\n        status === \"error\" && \"bg-muted text-muted-foreground text-sm\",\n        className\n      )}\n      style={{\n        aspectRatio,\n        backgroundImage: loading && placeholder ? `url(${JSON.stringify(placeholder)})` : undefined,\n        backgroundSize: loading && placeholder ? \"cover\" : undefined,\n        backgroundPosition: loading && placeholder ? \"center\" : undefined,\n        ...style,\n      }}\n      {...props}\n    />\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
