Media

Image Placeholder

A progressive image loader — skeleton, blur-up or color placeholder, a 400ms cross-fade on load and an error panel when the URL fails.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ImageOff } from "lucide-react"
import { cn } from "@/lib/utils"

type Phase = "loading" | "loaded" | "error"

const ROUNDED_CLASS = {
  none: "rounded-none",
  sm: "rounded-sm",
  md: "rounded-md",
  lg: "rounded-lg",
  xl: "rounded-xl",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/image-placeholder.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ImagePlaceholder" component
(lucide-react for the ImageOff icon).

Contract
- Export a forwardRef div extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "onLoad" | "onError"> — the DOM
  handlers are re-declared as simple () => void callbacks for the consumer.
- Props: src, alt (required); aspectRatio = "16/9" (any CSS ratio string);
  placeholder = "skeleton" | "blur" | "color" (default "skeleton");
  blurSrc? (a tiny low-resolution URL, only used by "blur");
  fit = "cover" | "contain"; rounded = none|sm|md|lg|xl|full (class table);
  priority = false; onLoad?; onError?.
- placeholder="blur" without blurSrc degrades to the plain color block — never
  blur the full-size image as its own placeholder.

Behavior
- Three phases: loading → loaded | error. Hold them in one state object
  { src, phase, placeholderGone } that remembers WHICH src it describes; when
  the src prop differs from state.src, derive a fresh loading state during
  render instead of resetting inside an effect. Changing src must always
  restart the sequence.
- Cached images are the trap: an image already in the browser cache can be
  `complete` before React attaches onLoad, and the load event will never fire
  again — that image would sit under the skeleton forever, and the consumer's
  onLoad would never run either. So add a callback ref on the <img> that
  probes node.complete on attach: has pixels (naturalWidth) means loaded, zero
  pixels means the request already failed and no error event is coming.
- Route both paths — the load/error events and the cache probe — through one
  settle(phase) that writes the state AND calls the consumer's onLoad/onError,
  and gate both on a `settled` boolean derived from the current phase. That
  way each src notifies exactly once no matter which path won the race, and a
  cache hit settles with the placeholder already removed (there is nothing to
  fade from).
- On load: the real <img> transitions opacity 0 → 1 over 400ms while the
  placeholder layer transitions 1 → 0 over the same 400ms; the placeholder
  unmounts in its own onTransitionEnd (guarded on propertyName === "opacity"),
  so there is no setTimeout to leak and an unmount mid-fade cleans itself up.
  Under prefers-reduced-motion there is no fade to wait for, so mark the
  placeholder as gone at settle time and let it unmount immediately.
- On error: skip the placeholder entirely and render an error layer —
  bg-muted, an ImageOff icon and the alt text as human-readable fallback
  copy (a broken image should still tell you what it was).
- Guard the transitionEnd updater with a stale check (only apply when the
  state still describes the current src) so a fade from the previous image
  can't resurrect the old record.

Rendering & styling
- The root is position: relative with aspect-[var(--zg-ratio)] where the
  ratio comes from an inline CSS custom property — the box occupies its final
  size before the image exists, which is what prevents layout shift; because
  it is a class (not an inline aspect-ratio), a consumer's `aspect-square` in
  className still wins through cn().
- Layers, all inset-0: placeholder (aria-hidden) → real image → error panel.
  The image is size-full with object-cover/contain from `fit`.
- Skeleton = bg-muted + animate-pulse with motion-reduce:[animation:none];
  blur = the low-res <img> at scale-110 blur-lg object-cover (the scale hides
  the blurred edges); color = a flat bg-muted block.
- <img> always carries decoding="async"; priority maps to loading="eager" +
  fetchPriority="high", otherwise loading="lazy" + fetchPriority="auto".
- Semantic tokens only: bg-muted, text-muted-foreground — the placeholder
  reads as "surface" in both light and dark themes with no color literals.

Customization levers
- Fade timing: the 400ms duration on the image and the placeholder must stay
  equal (they are two halves of one cross-fade); 200–600ms is the useful band.
- Placeholder look: add a "shimmer" variant by swapping animate-pulse for a
  translating gradient keyframe, or render children as a custom placeholder
  slot instead of the three built-ins.
- Ratio & crop: aspectRatio + fit are independent — "1/1" + contain for logos,
  "16/9" + cover for covers; pass rounded="full" for avatars.
- Error affordance: the error panel is one block — swap the alt text for a
  Retry button that bumps a key/cache-busting query if your images are worth
  retrying.
- Priority policy: set priority on the LCP image only; everything else should
  stay lazy so the placeholder actually earns its keep.

Concepts

  • Ratio-first box — the container claims its final size from aspect-ratio before a single byte arrives, so the page never reflows when the image lands (the whole point of a placeholder).
  • src-keyed state — the load state records which src it belongs to; a new URL invalidates it during render, which resets the state machine without an effect and without a stale "loaded" flash of the previous photo.
  • Cache-hit detection — a cached image can be complete before React wires onLoad, so the ref callback probes complete / naturalWidth on attach; skipping this is the classic bug where fast images stay stuck behind the skeleton. Both paths funnel into one settle() guarded by a settled flag, so onLoad / onError fire exactly once per URL.
  • Cross-fade, not swap — placeholder and image transition in opposite directions over the same duration, and the placeholder removes itself in transitionend rather than on a timer that could outlive the component.
  • Blur-up — a ~20px preview of the same photo, upscaled and blurred, gives the eye the final composition and colors while the full file streams in.
  • Error as a state, not an accident — a failed URL renders a labelled panel carrying the alt text instead of the browser's broken-image glyph, so the layout and the meaning both survive.

On This Page