Appearance
@vibe-labs/design-vue-images
Vue 3 image components for the Vibe Design System. LQIP blur-up crossfade, lazy loading, global load queue with concurrency control, and responsive <picture> support.
Installation
ts
import { VibeImage, VibeBgImage, VibePicture } from "@vibe-labs/design-vue-images";Requires the types from @vibe-labs/design-components-images. No CSS import needed (styles are inline/scoped).
Components
VibeImage
Standard image with LQIP blur-up crossfade, lazy loading, and auto aspect-ratio.
Usage
vue
<!-- Basic -->
<VibeImage src="/photos/hero.jpg" alt="Hero shot" />
<!-- LQIP blur-up -->
<VibeImage src="/photos/hero-full.jpg" thumb-src="/photos/hero-thumb.jpg" alt="Hero shot" />
<!-- Eager loading (above the fold) -->
<VibeImage src="/hero.jpg" alt="Hero" loading="eager" fetchpriority="high" />
<!-- Fixed dimensions -->
<VibeImage src="/avatar.jpg" alt="User" :width="200" :height="200" />
<!-- Fit and position -->
<VibeImage src="/photo.jpg" alt="Photo" fit="contain" position="top-center" />
<!-- Border radius (token shorthand) -->
<VibeImage src="/photo.jpg" alt="Photo" radius="lg" />
<VibeImage src="/photo.jpg" alt="Photo" radius="full" />
<!-- No crossfade -->
<VibeImage src="/photo.jpg" alt="Photo" :crossfade="false" />
<!-- Custom duration -->
<VibeImage src="/photo.jpg" alt="Photo" :duration="500" />
<!-- Srcset for responsive -->
<VibeImage
src="/photo-800.jpg"
srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w"
sizes="(max-width: 600px) 400px, 800px"
alt="Responsive"
/>
<!-- Polymorphic (render as different element) -->
<VibeImage :as="NuxtImg" src="/photo.jpg" alt="Via NuxtImg" />
<!-- Placeholder + error slots -->
<VibeImage src="/photo.jpg" alt="Photo">
<template #placeholder><MySkeleton /></template>
<template #error><MyErrorState /></template>
</VibeImage>Props
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | Image URL |
thumbSrc | string | — | Low-quality placeholder URL (blurred while full loads) |
alt | string | "" | Alt text |
srcset | string | — | Responsive srcset |
sizes | string | — | Responsive sizes |
width | string | number | — | Width |
height | string | number | — | Height |
fit | ImageFit | "cover" | object-fit |
position | ImagePosition | — | object-position |
loading | ImageLoadingStrategy | "lazy" | lazy (IntersectionObserver) or eager |
fetchpriority | FetchPriority | — | Browser fetch priority |
decoding | ImageDecoding | "async" | Decoding hint |
crossfade | boolean | true | Enable crossfade transition |
duration | number | 300 | Crossfade duration (ms) |
radius | string | — | Border radius (token name or CSS value) |
aspect | string | auto | Aspect ratio (auto-computed from intrinsic dimensions) |
draggable | boolean | false | HTML draggable |
as | string | Component | "img" | Polymorphic render element |
Events
| Event | Payload | Description |
|---|---|---|
load | (src: string, dimensions: ImageDimensions) | Full image loaded |
error | (src: string | undefined) | Image failed |
statusChange | (status: ImageStatus, src: string | null) | Status transition |
Slots
| Slot | Description |
|---|---|
placeholder | Custom loading placeholder |
error | Custom error state |
How LQIP Works
thumbSrcloads immediately (tiny, fast)- Displays blurred (
blur(20px)+scale(1.1)to hide edges) srcloads in background via the image queue- Once loaded, crossfades from blurred thumb to sharp full image
- Auto aspect-ratio computed from intrinsic dimensions to prevent CLS
VibeBgImage
Background image container with LQIP, overlay support, and slot for content.
Usage
vue
<!-- Basic -->
<VibeBgImage src="/hero.jpg" aria-label="Hero background">
<h1>Welcome</h1>
</VibeBgImage>
<!-- With overlay -->
<VibeBgImage src="/hero.jpg" :overlay="true">
<h1 class="text-white">Darkened overlay</h1>
</VibeBgImage>
<!-- Custom overlay gradient -->
<VibeBgImage src="/hero.jpg" overlay="linear-gradient(to bottom, transparent, rgba(0,0,0,0.8))">
<h1>Gradient overlay</h1>
</VibeBgImage>
<!-- Fixed (parallax-style) -->
<VibeBgImage src="/hero.jpg" fixed aria-label="Parallax background" />
<!-- LQIP -->
<VibeBgImage src="/hero-full.jpg" thumb-src="/hero-thumb.jpg" aria-label="Hero" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | Image URL |
thumbSrc | string | — | LQIP placeholder URL |
fit | BgFit | "cover" | background-size |
position | BgPosition | "center" | background-position |
loading | ImageLoadingStrategy | "lazy" | Loading strategy |
fixed | boolean | false | background-attachment: fixed |
overlay | string | boolean | — | Overlay gradient (true = 40% black) |
width / height | string | number | — | Container dimensions |
radius | string | — | Border radius |
aspect | string | — | Aspect ratio |
ariaLabel | string | — | Accessible label (sets role="img") |
VibePicture
Responsive <picture> element with multiple sources, LQIP, and crossfade. Smart rendering: thumb renders as plain <img> (blurred) to avoid <source> elements triggering premature full-res loads.
Usage
vue
<VibePicture
src="/photo.jpg"
thumb-src="/photo-thumb.jpg"
alt="Responsive photo"
:sources="[
{ media: '(min-width: 1200px)', srcset: '/photo-xl.webp', type: 'image/webp' },
{ media: '(min-width: 800px)', srcset: '/photo-lg.webp', type: 'image/webp' },
{ srcset: '/photo-sm.webp', type: 'image/webp' },
]"
/>Props
Inherits all VibeImage props except as, srcset, sizes, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
sources | PictureSource[] | [] | Responsive sources (media, srcset, type, sizes) |
Composables
useImageLoad(src)
Reactive image loader with generation tracking (prevents stale loads).
ts
import { useImageLoad } from "@vibe-labs/design-vue-images";
const src = ref("/photo.jpg");
const { status, currentSrc, dimensions } = useImageLoad(src);
// status: "idle" | "loading" | "loaded" | "error"useLazyLoad(target, options?)
IntersectionObserver-based visibility detection for lazy loading.
ts
import { useLazyLoad } from "@vibe-labs/design-vue-images";
const el = ref<HTMLElement | null>(null);
const { isVisible } = useLazyLoad(el, {
rootMargin: "200px",
threshold: 0,
enabled: () => props.loading === "lazy",
});useImageQueue() / imageQueue
Global singleton queue with configurable concurrency (default: 3 concurrent loads).
ts
import { useImageQueue } from "@vibe-labs/design-vue-images";
const { queue, clear, setConcurrency } = useImageQueue();
setConcurrency(5);
clear(); // cancel pending loads (e.g. on route change)useRadius(radius)
Maps radius token shorthand to CSS values.
ts
import { useRadius } from "@vibe-labs/design-vue-images";
const radius = useRadius(toRef(props, "radius"));
// "lg" → "var(--radius-lg)"
// "full" → "var(--radius-full)"
// "8px" → "8px"Image Statuses
| Status | Description |
|---|---|
idle | No src set |
loading | Image loading (or thumb loaded, full pending) |
loaded | Full image loaded |
error | Load failed |
Dependencies
| Package | Purpose |
|---|---|
@vibe-labs/design-components-images | Type definitions and constants |
Build
bash
npm run buildBuilt with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.
Usage Guide
Setup
ts
import { VibeImage, VibeBgImage, VibePicture } from "@vibe-labs/design-vue-images";
// No CSS import needed — styles are inline/scopedVibeImage
Basic image with LQIP blur-up
vue
<script setup lang="ts">
import { VibeImage } from "@vibe-labs/design-vue-images";
defineProps<{ src: string; thumbSrc: string; alt: string }>();
</script>
<template>
<!-- thumbSrc shows blurred immediately, then crossfades to full src when loaded -->
<VibeImage
:src="src"
:thumb-src="thumbSrc"
:alt="alt"
radius="md"
/>
</template>Hero image — above the fold
vue
<template>
<!-- Eager + high fetchpriority for LCP images -->
<VibeImage
src="/hero-1200.jpg"
srcset="/hero-600.jpg 600w, /hero-1200.jpg 1200w, /hero-2400.jpg 2400w"
sizes="100vw"
alt="Festival crowd"
loading="eager"
fetchpriority="high"
fit="cover"
/>
</template>Image with error fallback
vue
<template>
<VibeImage src="/artist-photo.jpg" alt="Artist photo">
<template #placeholder>
<div class="skeleton" style="width: 100%; height: 200px" />
</template>
<template #error>
<div class="placeholder-image">No photo available</div>
</template>
</VibeImage>
</template>VibeBgImage
Hero section with gradient overlay
vue
<script setup lang="ts">
import { VibeBgImage } from "@vibe-labs/design-vue-images";
</script>
<template>
<VibeBgImage
src="/event-hero.jpg"
thumb-src="/event-hero-thumb.jpg"
overlay="linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.85))"
aspect="21x9"
aria-label="Event hero"
>
<div style="position: absolute; bottom: 32px; left: 32px; color: white">
<h1>Warehouse Project</h1>
<p>Manchester · Sat 12 April</p>
</div>
</VibeBgImage>
</template>VibePicture
Responsive art-directed picture
vue
<template>
<VibePicture
src="/photo-fallback.jpg"
thumb-src="/photo-thumb.jpg"
alt="Track artwork"
:sources="[
{ media: '(min-width: 1024px)', srcset: '/photo-xl.webp', type: 'image/webp' },
{ media: '(min-width: 640px)', srcset: '/photo-lg.webp', type: 'image/webp' },
{ srcset: '/photo-sm.webp', type: 'image/webp' },
]"
radius="lg"
/>
</template>Composables
useImageQueue — control concurrency
vue
<script setup lang="ts">
import { useImageQueue } from "@vibe-labs/design-vue-images";
import { onMounted, onBeforeUnmount } from "vue";
const { setConcurrency, clear } = useImageQueue();
onMounted(() => {
// Allow more parallel loads on fast connections
setConcurrency(6);
});
onBeforeUnmount(() => {
// Cancel pending loads when leaving the page
clear();
});
</script>Common Patterns
Album art in a track row
vue
<script setup lang="ts">
import { VibeImage } from "@vibe-labs/design-vue-images";
defineProps<{ title: string; artwork: string; artworkThumb: string }>();
</script>
<template>
<div style="display: flex; align-items: center; gap: 12px">
<VibeImage
:src="artwork"
:thumb-src="artworkThumb"
:alt="`${title} artwork`"
:width="48"
:height="48"
radius="sm"
/>
<span>{{ title }}</span>
</div>
</template>