Headless image component with state-driven placeholder and error fallback. Tracks the loading lifecycle and exposes retry(), with optional IntersectionObserver-driven lazy loading.
Image.Root owns the loading state machine via useImage. Image.Img renders the image element and reports load and error events to the context. Image.Placeholder is shown while idle or loading; Image.Fallback is shown on error.
src is the only native image attribute that doesn’t live on Image.Img because it drives the load state machine — the idle → loading → loaded | error lifecycle can’t function without knowing the URL to track, so Image.Root owns it. Every other image attribute (alt, width, height, srcset, sizes, crossorigin, referrerpolicy, decoding, loading, fetchpriority) is a pure rendering concern and lives on Image.Img, where it describes the element that actually renders it.
Image.Root calls useImage to manage status, and optionally wires up useIntersectionObserver when the lazy prop is set. Children consume the root context via useImageRoot.
Wrap Image.Root in a reusable component that drives a fade-in transition from a low-quality placeholder (LQIP) to the full-resolution image. A tiny blurred thumbnail ships in the initial HTML (or as a small base64 string), visible immediately; the full image is withheld by the lazy prop until Image.Root intersects the viewport, at which point it loads and fades in over the blur.
Use this when many images sit below the fold. A placeholder is visible immediately, and the full image loads only as it nears the viewport. Galleries and article covers are the usual case.
Three pieces make it work:
lazy prop on Image.Root — defers setting the real src until the container scrolls into view via useIntersectionObserver. Pair with root-margin (e.g. "200px") to start loading a bit before the container actually intersects.
LQIP inside Image.Placeholder — the blurred preview sits in the reserved frame (typically absolute inset-0). It’s decorative, so Image.Placeholder auto-sets aria-hidden="true".
data-state-driven opacity — the real Image.Img uses opacity-0 transition-opacity data-[state=loaded]:opacity-100, so the fade-in is CSS-only and doesn’t require threading slot props through the template.
Gotcha: the lazy prop needs a wrapper element to observe — it warns and skips observation when combined with renderless. If you need a lazy <picture>, observe a parent yourself with useIntersectionObserver and gate the source manually via useImage’s eager option.
File
Role
BlurUpImage.vue
Reusable component with LQIP placeholder, lazy intersection loading, and CSS-driven fade-in
observer.vue
Entry point rendering a scrollable list of blur-up images to demonstrate the viewport trigger
A low-quality placeholder shows first; the full image fades in once it loads. Scroll to trigger the next image.
Use renderless mode on both Image.Root and Image.Img to drop their wrapper elements and compose them directly inside a native <picture> element. The browser walks the <source> list and picks the first format it supports (WebP, AVIF, JPEG XL, etc.); Image.Img renders the fallback <img> that older browsers use. All loading state is still tracked by the surrounding Image.Root, so Image.Placeholder and Image.Fallback behave identically to the compound form — no extra wiring needed.
Reach for this pattern when you’re serving modern image formats for bandwidth or quality gains while keeping a universal fallback, or when you need full control over DOM structure (e.g., embedding inside a <figure> with a <figcaption>). It’s the only way to use Image.Root’s state machine with a native <picture> without losing either the format negotiation or the state-driven placeholder/fallback pattern.
Two details:
renderless on both — Image.Root must go renderless to avoid wrapping <picture> in an extra <div>, and Image.Img must go renderless so you can place the inner <img> as <picture>'s last child (where the browser expects it).
lazy doesn’t work here — observer-driven lazy loading relies on a wrapper element to measure against. Combine native loading="lazy" on the inner <img> with fetchpriority hints for equivalent deferred loading, or wrap the whole composition in an outer element you observe manually.
The <source> children are fully browser-driven — Image.Img never sees their URLs. It just reports the final <img> element’s load/error events back to the context.
File
Role
PictureImage.vue
Reusable wrapper that emits a <picture> with typed <source> children; both Image.Root and Image.Img go renderless to drop wrapper elements
picture.vue
Entry point passing WebP and fallback sources to demonstrate format negotiation
Every Image sub-component exposes data-state reflecting the current status (idle, loading, loaded, or error). Prefer styling against these data attributes with CSS over threading slot props — the transitions stay CSS-only and the template stays declarative.
/* Or with plain CSS */[data-state='loaded'] { opacity: 1; }[data-state='loading'] { animation: pulse 1s infinite; }[data-state='error'] { border-color: red; }
Slot props (status, isLoaded, etc.) remain available for the rare cases where logic has to branch in the template, but reach for CSS + data attributes first.
Avatar is for identity / profile UIs with priority-based multi-source fallback (e.g. high-res then low-res then initials). Image is for general content images with state-driven placeholder and fallback for a single source.
Use native loading="lazy" for most cases — it’s declarative, zero-JS, and browser-managed. Use the lazy prop on Image.Root only when you need to control exactly when the source is set, such as for blur-up transitions, prefetching, or custom intersection thresholds.
Always set width and height on Image.Img. The browser uses these to reserve space before the image loads, eliminating Cumulative Layout Shift.
Yes — set renderless on both Image.Root and Image.Img, then compose them inside a native <picture> element. See the picture example.
Style against the data-state attribute that every Image sub-component exposes. Using data attributes keeps the transition CSS-only — no slot-prop threading required.
The data-state attribute holds the current status (idle, loading, loaded, or error). The blur-up example uses this pattern.
src lives on Image.Root because it owns the state machine — the source is what the state is about. Image.Img accepts the rendering attributes (alt, width, height, srcset, sizes, etc.) that belong to the rendered element.
Discord
Need help? Join our community for support and discussions ↗