Image
Headless image component with state-driven placeholder and error fallback. Tracks the loading lifecycle and exposes retry(), with optional IntersectionObserver-driven lazy loading.
Usage
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.
<script setup lang="ts">
import { Image } from '@vuetify/v0'
</script>
<template>
<div class="flex items-center justify-center">
<Image.Root
class="w-100 h-75 bg-surface-tint rounded overflow-hidden relative"
src="https://picsum.photos/seed/v0-image/400/300"
>
<Image.Img
alt="Random photo"
class="w-full h-full object-cover"
height="300"
loading="lazy"
width="400"
/>
<Image.Placeholder class="absolute inset-0 flex items-center justify-center">
<div class="w-full h-full bg-surface-tint animate-pulse" />
</Image.Placeholder>
<Image.Fallback class="absolute inset-0 flex items-center justify-center bg-error-container text-on-error-container text-sm">
Image failed to load
</Image.Fallback>
</Image.Root>
</div>
</template>
Anatomy
<script setup lang="ts">
import { Image } from '@vuetify/v0'
</script>
<template>
<Image.Root>
<Image.Img />
<Image.Placeholder />
<Image.Fallback />
</Image.Root>
</template>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.
Architecture
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.
Lazy loading strategies
The component supports two distinct lazy loading mechanisms — pick the one that matches your requirements.
| Strategy | How | When to use |
|---|---|---|
| Native | loading="lazy" on Image.Img | Most below-the-fold images. Zero JS, browser-managed. |
| Observer | lazy prop on Image.Root | When you need precise control over when the source is set — e.g. blur-up transitions, prefetch, or custom intersection thresholds. |
You can combine both: use the observer for state control and loading="lazy" as a fallback for browsers without IntersectionObserver support.
Examples
Blur-up LQIP with observer loading
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.
Reach for this pattern when you have many below-the-fold images and want both perceived performance (something visible instantly) and actual bandwidth savings (only load what’s seen). It shines on photo galleries, article covers, and image-heavy marketing pages where the blur-up effect is part of the aesthetic, not just a loading trick.
Three pieces make it work:
lazyprop onImage.Root— defers setting the realsrcuntil the container scrolls into view viauseIntersectionObserver. Pair withroot-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 (typicallyabsolute inset-0). It’s decorative, soImage.Placeholderauto-setsaria-hidden="true".data-state-driven opacity — the realImage.Imgusesopacity-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.
Picture element with format negotiation
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 worth knowing:
renderlesson both —Image.Rootmust go renderless to avoid wrapping<picture>in an extra<div>, andImage.Imgmust go renderless so you can place the inner<img>as<picture>'s last child (where the browser expects it).lazydoesn’t work here — observer-driven lazy loading relies on a wrapper element to measure against. Combine nativeloading="lazy"on the inner<img>withfetchpriorityhints 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 |
Recipes
Hero image with high priority
Set loading="eager" and fetchpriority="high" on hero images to optimize LCP. Always include width and height to prevent layout shift.
<template>
<Image.Root src="/hero.jpg">
<Image.Img
alt="Hero"
width="1600"
height="900"
loading="eager"
fetchpriority="high"
/>
<Image.Placeholder>
<img src="/hero-tiny.jpg" class="w-full blur-sm" />
</Image.Placeholder>
</Image.Root>
</template>Retry on error
The retry() function is available from both Image.Root and Image.Fallback slot props.
<template>
<Image.Root src="/photo.jpg">
<Image.Img alt="Photo" />
<Image.Placeholder>Loading...</Image.Placeholder>
<Image.Fallback v-slot="{ retry }">
<button @click="retry">Retry</button>
</Image.Fallback>
</Image.Root>
</template>Styling
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.
<template>
<Image.Img
alt="Photo"
class="opacity-0 transition-opacity data-[state=loaded]:opacity-100"
/>
</template>/* 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.
Accessibility
| Element | ARIA / behavior |
|---|---|
Image.Img | role="img", accepts alt for accessible name |
Image.Placeholder | aria-hidden="true" — placeholder is decorative |
Image.Fallback | role="img" — provide alternate text inside the slot |
Always pass width and height props on Image.Img to reserve layout space and prevent Cumulative Layout Shift while the image loads.
FAQ
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.
<template>
<Image.Img
alt="Photo"
class="opacity-0 transition-opacity duration-500 data-[state=loaded]:opacity-100"
/>
</template>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.
Image.Root
Props
rootMargin
string | undefinedIntersectionObserver root margin. Only used when lazy is true.
Default: "0px"
threshold
number | number[] | undefinedIntersectionObserver threshold(s). A number or array of numbers between 0.0 and 1.0 indicating the percentage of the target's visibility required to trigger the observer. Only used when lazy is true.
Default: 0
root
Element | null | undefinedIntersectionObserver root element. If omitted, the viewport is used. Pass a scroll container to observe intersections relative to it instead. Only used when lazy is true.
Default: null
Slots
default
ImageRootSlotPropsImage.Fallback
Props
Slots
default
ImageFallbackSlotPropsImage.Img
Props
decoding
"sync" | "async" | "auto" | undefinedHow the browser should decode the image.
Default: "async"
Events
Slots
default
ImageImgSlotPropsImage.Placeholder
Props
Slots
default
ImagePlaceholderSlotProps