Skip to content

Reference

TypeScript types

All public types are exported from the package root:

ts
import type {
  ImageStatus, // 'idle' | 'loading' | 'loaded' | 'error'
  SrcSet, // { avif?: string; webp?: string; fallback: string }
  ResponsiveSrc, // Record<string, string | SrcSet> — breakpoint-key → URL, or a format set for that breakpoint
  BreakpointMap, // Record<string, string> — breakpoint-key → CSS media query
  VImageKitOptions, // { breakpoints?: BreakpointMap }
  LazyImgOptions, // { src, placeholder?, rootMargin?, threshold?, onLoad?, onError? }
  ObjectFit, // 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
  FocalPoint, // { x: number; y: number } — fractions 0–1
  Densities, // number[] | Record<number, string> — density descriptors
  ImageMeta, // CLI manifest entry / `?vik` import shape, for the `image` prop
  Layout, // 'fixed' | 'responsive' | 'fill' — the `layout` prop
} from 'vue-image-kit'

ImageStatus

ts
type ImageStatus = 'idle' | 'loading' | 'loaded' | 'error'

The state machine transitions in order: idle → loading → loaded or idle → loading → error.

SrcSet

ts
interface SrcSet {
  avif?: string // Optional AVIF source URL
  webp?: string // Optional WebP source URL
  fallback: string // Required — used as the <img src> fallback
}

LazyImgOptions

ts
interface LazyImgOptions {
  src: string
  placeholder?: string
  rootMargin?: string
  threshold?: number
  onLoad?: () => void
  onError?: (e: Event) => void
}

The v-lazy-img directive accepts either a plain string (the src) or a LazyImgOptions object.

Working with typed options in v-lazy-img

ts
import type { LazyImgOptions } from 'vue-image-kit'

const bgOptions: LazyImgOptions = {
  src: '/hero.jpg',
  placeholder: 'data:image/jpeg;base64,...',
  rootMargin: '100px',
  onLoad: () => analytics.track('hero_loaded'),
}
vue
<div v-lazy-img="bgOptions" class="hero" />

SSR compatibility

ScenarioBehaviour
Server render — <VImage>Renders <img loading="lazy"> with src and alt; no IO, no canvas
Server render — aspect-ratioA <div> with aspect-ratio: width/height is rendered when width and height are provided
Blurhash on serverCanvas code is inside onMounted — not executed; a blank container is rendered instead
IntersectionObserver on serverNot used; the server renders a plain <img>
HydrationAfter mount, onMounted sets up IO (if lazy: true) or immediately starts loading (if lazy: false)
v-lazy-img on serverDirective hooks (mounted, unmounted) are not called during SSR — no IO is created
useLazyLoad on serverReturns { isIntersecting: true } immediately — the caller proceeds as if in-viewport

Nuxt usage:

No special configuration is required. The component renders correctly in both SSR and client modes. If you need to know whether the client has mounted, use Vue's onMounted:

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const mounted = ref(false)
onMounted(() => {
  mounted.value = true
})
</script>

<template>
  <VImage v-if="mounted" src="/photo.jpg" alt="Photo" blurhash="..." />
  <div v-else style="aspect-ratio: 16/9; background: #e5e7eb;" />
</template>

Architecture

VImage.vue
│  props: src, alt, width, height,
│         blurhash, thumbhash, placeholder,
│         widths, sizes, sources, breakpoints,
│         lazy, rootMargin, threshold, fit,
│         maxRetries, retryDelay,
│         fetchpriority, decoding

├──▶ useImage(options)
│         │
│         ├── useLazyLoad({ rootMargin, threshold })
│         │      IntersectionObserver (SSR-safe)
│         │      isIntersecting: Ref<boolean>
│         │      observe(elRef) → starts watching
│         │
│         ├── State machine
│         │      idle → loading → loaded
│         │                    → error (retryCount >= maxRetries)
│         │                    → idle → loading  (retry, exponential backoff)
│         │      lazy=true  → watch(isIntersecting) → loading
│         │      lazy=false → onMounted → loading
│         │
│         └── imgAttrs: ComputedRef
│                src    = fallback URL
│                srcset = generateSrcset(src, widths)
│                sizes  = generateSizes(sizes)
│                style  = { objectFit: fit }

├──▶ useBlurhash({ blurhash, width, height })
│         onMounted → decodeBlurhash(hash, width, height)
│                   → new ImageData(pixels, width, height)
│                   → ctx.putImageData(imageData, 0, 0)
│         canvasRef: Ref<HTMLCanvasElement | null>
│         SSR: returns null ref (canvas code never runs)

├──▶ useBreakpoints(breakpoints?)
│         Merges local breakpoints prop with global plugin breakpoints
│         resolveMediaSources(sources) → sorted [{ media, src }]

├──▶ effectivePlaceholder: ComputedRef<string | undefined>
│         placeholder prop  → used as-is (LQIP base64)
│         thumbhash prop    → decodeThumbHash(hash) → PNG data URL
│         neither           → undefined (no blur-up placeholder)

├──▶ Template structure (client)
│      <span wrapper :style="{ aspectRatio, position: relative }">
│        <canvas v-if="blurhash && width && height && !isError" />
│                                              ← BlurHash canvas placeholder
│        <img aria-hidden
│             v-if="effectivePlaceholder && !isError" />
│                                              ← LQIP / ThumbHash blur-up
│        <span v-if="isError">                ← error state
│          <slot name="error"><svg .../></slot>
│        </span>
│        <picture v-if="shouldRenderImg && !isError && needsPicture">
│                                              ← format/art-direction sources
│          <source v-for media/srcset />       ← responsive art direction
│          <source type="image/avif" />
│          <source type="image/webp" />
│          <img v-bind="imgAttrs" :decoding :fetchpriority @load @error />
│        </picture>
│        <img v-if="shouldRenderImg && !isError && !needsPicture"
│             v-bind="imgAttrs" :decoding :fetchpriority @load @error />
│                                              ← simple img (no picture)
│        <span v-if="isIdle && !blurhash && !effectivePlaceholder" />
│                                              ← grey background (no placeholder)
│      </span>

└──▶ Template structure (SSR)
       <img :src :alt :width :height :decoding :fetchpriority
            :loading="lazy ? 'lazy' : 'eager'" />

vLazyImg (Directive)
│  mounted(el, binding)
│    resolveOptions(binding) → { src, placeholder, rootMargin, ... }
│    createObserver(el, options)
│      IntersectionObserver → on intersect:
│        if placeholder: el.style.backgroundImage = url(placeholder)
│        new Image()
│          onload  → el.style.backgroundImage = url(src); onLoad()
│          onerror → onError(e)
│  updated  → disconnect old observer, create new one
│  unmounted → observer.disconnect()

Utils (pure functions, zero Vue deps)
│  blurhash-decode.ts
│    decodeBlurhash(hash, width, height) → Uint8ClampedArray  ← RGBA pixels

│  thumbhash-decode.ts
│    decodeThumbHash(hash: string | Uint8Array) → string      ← PNG data URL

└── srcset.ts
    generateSrcset(src, widths) → string
    generateSizes(sizes?) → string
    buildSizes(map, breakpoints) → string
    generatePreloadLink(href, options) → string

Bundle size & peer dependencies

Entry pointRawGzipPeer deps
vue-image-kit ESM42.1 kB13.0 kBvue ^3.0
vue-image-kit CJS31.9 kB11.3 kBvue ^3.0
vue-image-kit/cdn ESM10.8 kB2.4 kB

Measured from the actual build output (npm run build), not maintained by hand — CI fails if this drifts past the thresholds in .github/workflows/ci.yml.

Ships as tree-shakeable ESM (vue-image-kit.js) and CommonJS (vue-image-kit.cjs). "sideEffects": false in package.json — unused exports are eliminated by the bundler. If you only import vLazyImg or a single composable, the bundler will exclude everything else (VImage, blurhash decoder, etc.).

Tree-shaking example — use only the directive:

ts
// Only vLazyImg and its IO logic is included in the bundle.
// VImage, useBlurhash, decodeBlurhash are not imported → not bundled.
import { vLazyImg } from 'vue-image-kit'
app.directive('lazy-img', vLazyImg)

License

MIT