Skip to content

@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, VibeSvg } 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

PropTypeDefaultDescription
srcstringImage URL
thumbSrcstringLow-quality placeholder URL (blurred while full loads)
altstring""Alt text
srcsetstringResponsive srcset
sizesstringResponsive sizes
widthstring | numberWidth
heightstring | numberHeight
fitImageFit"cover"object-fit
positionImagePositionobject-position
loadingImageLoadingStrategy"lazy"lazy (IntersectionObserver) or eager
fetchpriorityFetchPriorityBrowser fetch priority
decodingImageDecoding"async"Decoding hint
crossfadebooleantrueEnable crossfade transition
durationnumber300Crossfade duration (ms)
radiusstringBorder radius (token name or CSS value)
aspectstringautoAspect ratio (auto-computed from intrinsic dimensions)
draggablebooleanfalseHTML draggable
asstring | Component"img"Polymorphic render element

Events

EventPayloadDescription
load(src: string, dimensions: ImageDimensions)Full image loaded
error(src: string | undefined)Image failed
statusChange(status: ImageStatus, src: string | null)Status transition

Slots

SlotDescription
placeholderCustom loading placeholder
errorCustom error state

How LQIP Works (3-slot crossfade)

The component uses a 3-slot system (back/front/pending) to handle rapid source changes gracefully:

  • Back slot (backSrc): Currently visible, fully opaque
  • Front slot (frontSrc): Fading in over the back slot
  • Pending slot (pendingSrc): Queued source, not yet rendered — freely overwritten if the source changes again before the front finishes fading
  1. thumbSrc loads immediately (tiny, fast) into the back slot
  2. Displays blurred (blur(20px) + scale(1.1) to hide edges)
  3. src loads in background via the image queue
  4. Once loaded, the full image enters the front slot and crossfades over the back
  5. When the transition ends, front promotes to back and any pending source starts fading
  6. 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

PropTypeDefaultDescription
srcstringImage URL
thumbSrcstringLQIP placeholder URL
fitBgFit"cover"background-size
positionBgPosition"center"background-position
loadingImageLoadingStrategy"lazy"Loading strategy
fixedbooleanfalsebackground-attachment: fixed
overlaystring | booleanOverlay gradient (true = 40% black)
width / heightstring | numberContainer dimensions
radiusstringBorder radius
aspectstringAspect ratio
ariaLabelstringAccessible 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:

PropTypeDefaultDescription
sourcesPictureSource[][]Responsive sources (media, srcset, type, sizes)


VibeSvg

Inline SVG renderer. Fetches an SVG file, inlines it into the DOM, and optionally replaces named colors. Fetched SVGs are cached in memory to avoid repeat requests.

Usage

vue
<!-- Basic -->
<VibeSvg src="/icons/logo.svg" alt="Logo" />

<!-- With sizing -->
<VibeSvg src="/icons/logo.svg" alt="Logo" :width="48" :height="48" />

<!-- Color replacement -->
<VibeSvg
  src="/icons/icon.svg"
  alt="Icon"
  :colors="{ '#000': 'var(--text-primary)', '#ff0000': 'var(--color-danger)' }"
/>

<!-- Border radius -->
<VibeSvg src="/illustrations/hero.svg" alt="Hero" radius="lg" />

Props

PropTypeDefaultDescription
srcstringURL to the SVG file
altstring""Accessible label (sets role="img" + aria-label)
widthstring | numberWidth (CSS value or number in px)
heightstring | numberHeight (CSS value or number in px)
radiusstringBorder radius (token name or CSS value)
colorsRecord<string, string>Color map — replace named colors in the SVG source

Composables

useSvgFetch(src)

Fetches an SVG file as text, with in-memory caching and in-flight request deduplication. Returns reactive svgContent and error refs.

ts
import { useSvgFetch } from "@vibe-labs/design-vue-images";

const src = ref("/icons/logo.svg");
const { svgContent, error } = useSvgFetch(src);
// svgContent: Ref<string | null> — the raw SVG markup
// error: Ref<boolean> — true if the fetch failed

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

StatusDescription
idleNo src set
loadingImage loading (or thumb loaded, full pending)
loadedFull image loaded
errorLoad failed

Dependencies

PackagePurpose
@vibe-labs/design-components-imagesType definitions and constants

Build

bash
npm run build

Built with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.


Usage Guide

Setup

ts
import { VibeImage, VibeBgImage, VibePicture, VibeSvg } from "@vibe-labs/design-vue-images";
// No CSS import needed — styles are inline/scoped

VibeImage

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>

VibeSvg

Inline SVG with color replacement

vue
<script setup lang="ts">
import { VibeSvg } from "@vibe-labs/design-vue-images";
</script>

<template>
  <!-- Basic inline SVG -->
  <VibeSvg src="/icons/logo.svg" alt="Logo" :width="32" :height="32" />

  <!-- Replace colors for theming -->
  <VibeSvg
    src="/icons/chart.svg"
    alt="Chart"
    :colors="{ '#000000': 'var(--text-primary)', '#0066ff': 'var(--color-accent)' }"
    :width="200"
  />
</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>

Vibe