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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/image-placeholder.jsonPrompt
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-ratiobefore 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
srcit 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
completebefore React wiresonLoad, so the ref callback probescomplete/naturalWidthon attach; skipping this is the classic bug where fast images stay stuck behind the skeleton. Both paths funnel into onesettle()guarded by asettledflag, soonLoad/onErrorfire 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
transitionendrather 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.