Reference
SSR compatibility
- Server render —
<VImage>— renders exactly one<img loading="lazy">withsrc/alt/nativewidth/height; no IO, no canvas, no wrapper element — all the placeholder machinery lives in the client-only branch. - Reserving space — done via the native HTML
width/heightattributes on the<img>itself, not a separate<div>with CSSaspect-ratio. - Blurhash on server — the decode step (an in-memory canvas) never runs at all; the SSR branch doesn't know about
hazehash/blurhash/thumbhash/placeholder. IntersectionObserveron server — not used; the server renders a plain<img>.- Hydration — after mount,
onMountedsets up IO (iflazy: true) or immediately starts loading (iflazy: false). v-lazy-imgon server — directive hooks (mounted,unmounted) are not called during SSR — no IO is created.useLazyLoadon server — returns{ 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:
<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, image, alt, width, height,
│ hazehash, blurhash, thumbhash, placeholder, ssrPlaceholder,
│ placeholderMode, placeholderColor,
│ widths, densities, sizes, sources, breakpoints,
│ lazy, rootMargin, threshold, fit, focal,
│ maxRetries, retryDelay,
│ fetchpriority, decoding, priority, respectSaveData,
│ layout, cdn, loader, loaderRoute, fadeIn
│
├──▶ 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
│ │ respectSaveData + isSaveDataEnabled() → neutralizes priority,
│ │ downgrades src via pickSmallestSrcsetUrl
│ │
│ └── imgAttrs: ComputedRef
│ src = fallback URL (or cdn/loader-rewritten URL)
│ srcset = generateSrcset(src, widths) / adapter.srcset() / handler URLs
│ sizes = generateSizes(sizes)
│ style = { objectFit: fit }
│
├──▶ blurhashToDataUrl(hash, width, height) ← local helper, NOT useBlurhash()
│ decodeBlurhash(hash, w, h) → pixels
│ offscreen canvas (created via document.createElement, never mounted)
│ → ctx.putImageData(...) → canvas.toDataURL()
│ returns: string | undefined (a data: URL, or undefined on decode
│ failure/SSR — invalid input silently leaves it blank)
│ useBlurhash() (below) is a separate, standalone composable —
│ see use-blurhash.md
│
├──▶ useBreakpoints(breakpoints?)
│ Merges local breakpoints prop with global plugin breakpoints
│ resolveMediaSources(sources) → sorted [{ media, src, type? }]
│
├──▶ cdn / loader resolution
│ cdn → autoLoader()/autoSrcset() (vue-image-kit/cdn)
│ loader → useServerRoute() + buildImageUrl() (vue-image-kit/server)
│ cdn wins if both are set
│
├──▶ effectivePlaceholder: ComputedRef<string | undefined>
│ placeholder prop → used as-is (LQIP base64)
│ thumbhash prop → decodeThumbHash(hash) → PNG data URL
│ placeholderMode='color' → thumbHashToAverageColor(thumbhash) / placeholderColor
│ placeholderMode='shimmer' → CSS-only skeleton, no hash needed
│ hazehash / blurhash renders → undefined (they win, see priority below)
│ neither → undefined (no blur-up placeholder)
│
├──▶ placeholderBackgroundStyle: ComputedRef<CSSProperties>
│ Same priority every placeholder always had (color → shimmer →
│ hazehash → blurhash → LQIP/ThumbHash), just expressed as CSS now instead of
│ separate DOM nodes:
│ isLoaded → {} (the photo itself is the content)
│ placeholderColor / mode=color → { backgroundColor }
│ mode='shimmer' → {} (the .vik-shimmer CSS class
│ below does the gradient+animation)
│ hazehashUrl / blurhashToDataUrl(...) result → { backgroundImage: url(...),
│ backgroundSize: 'cover',
│ backgroundPosition }
│ effectivePlaceholder (LQIP/ThumbHash) → same shape, as background-image
│ none of the above → { backgroundColor: '#f3f4f6' } (neutral fallback)
│
├──▶ Template structure (client) — exactly ONE of these renders at any
│ given moment, never nested inside another (see "Layout without a
│ wrapper" in responsive-images.md):
│
│ isIdle
│ <img ref="observeTargetRef" :src="blankSrc" alt="" aria-hidden="true"
│ :width="mergedWidth" :height="mergedHeight"
│ :style="{ aspectRatio, ...fixedSizeStyle, ...placeholderBackgroundStyle }"
│ :class="[loadedBoxClasses, { 'vik-fit': usesDefaultFit, 'vik-shimmer': showShimmerClass }]" />
│ ← IntersectionObserver target; carries
│ whatever placeholderBackgroundStyle resolved to
│
│ isError ← retries exhausted
│ <span :style="{ ...boxStyle, display: 'flex', ... }">
│ <slot name="error"><svg .../></slot>
│ </span>
│
│ needsPicture ← art-direction/format sources needed
│ <picture :style="{ display: boxStyle.display }">
│ <source v-for media/srcset /> ← responsive art direction
│ <source type="image/avif" />
│ <source type="image/webp" />
│ <img v-bind="imgAttrs" :style="{ ...boxStyle, objectFit,
│ ...placeholderBackgroundStyle }"
│ :class="{ 'vik-shimmer': showShimmerClass }"
│ :decoding :fetchpriority @load @error />
│ </picture>
│
│ otherwise (loading | loaded, no picture needed)
│ <img v-bind="imgAttrs" :style="{ ...boxStyle, objectFit,
│ ...placeholderBackgroundStyle }"
│ :class="{ 'vik-shimmer': showShimmerClass }"
│ :decoding :fetchpriority @load @error />
│ ← simple img (no picture)
│
└──▶ Template structure (SSR)
<img :src :alt :width :height :decoding :fetchpriority
:loading="lazy ? 'lazy' : 'eager'" />
vLazyImg (Directive)
│ mounted(el, binding)
│ resolveOptions(binding) → { src, placeholder, rootMargin, ... }
│ getPooledObserver(rootMargin, threshold) ← shared observer-pool.ts, same pool
│ useLazyLoad/useImage/VImage/useBackgroundImage use
│ 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()
useBackgroundImage(src, options) ← srcset-capable counterpart of vLazyImg
│ useLazyLoad({ rootMargin, threshold })
│ style: ComputedRef<{ backgroundImage, backgroundSize, ... }>
│ plain src → url(src)
│ densities → image-set(url(src) 1x, url(src) 2x, ...)
useNetworkAware() / isSaveDataEnabled()
│ navigator.connection.saveData / .effectiveType (Chromium-only API)
│ Used by: VImage's respectSaveData prop, useImagePreloader's preload()
useImagePreloader()
│ preload(urls) → Promise<void>, skipped entirely when isSaveDataEnabled()
│ loaded / total / progress / isComplete / errors
useServerLoader — useServerRoute(localOverride?) / SERVER_ROUTE_KEY
│ Resolves the on-demand server route: localOverride → plugin/module
│ provide()'d default → '/_vik/image' (DEFAULT_SERVER_ROUTE)
vue-image-kit/cdn (12 adapters, zero deps)
│ cloudinary / imgix / bunny / sanity / storyblok / contentful /
│ vercel / cloudflare / imagekit / twicpics / netlify / gumlet
│ Shared interface: adapter.url(path, options?) / adapter.srcset(path, widths, options?)
│ autoLoader() / autoSrcset() — hostname fingerprinting for 8 of the 12
vue-image-kit/server
│ createImageHandler(options) → (req, res) => void — framework-agnostic
│ buildImageUrl(src, options) → request URL string
│ Disk-cached, sharp-based, one transform per request
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
│ thumbHashToAverageRGBA(hash) / thumbHashToAverageColor(hash)
│
│ encode.ts (browser-only, in-house — no `thumbhash` package dependency)
│ encodeBlurhash(source, options?) / encodeThumbHash(source, options?)
│
└── srcset.ts
generateSrcset(src, widths) → string
generateSizes(sizes?) → string
generateDensitySrcset(src, densities) → string
buildSizes(map, breakpoints) → string
generatePreloadLink(href, options) → string
pickSmallestSrcsetUrl(srcset) → string | undefined
cli/ + vite/plugin.ts (Node-only, sharp-based, not in the browser bundle)
│ bin.ts — `vue-image-kit generate` command + flag parsing
│ processor.ts — resize/convert/LQIP/BlurHash/ThumbHash, SVG/GIF special-casing
│ incremental.ts — mtime/hash-based skip cache
│ manifest.ts — writes the TypeScript images.ts manifest
│ vite/plugin.ts — same generate() on buildStart/handleHotUpdate,
│ ?vik/?thumbhash resolvers, dev.onDemand handler
nuxt/module.ts (Node-only)
│ Registers VImage/v-lazy-img as global, auto-imports composables/utils,
│ provides breakpoints/serverRoute, optional onDemandServer → addServerHandlerBundle size & peer dependencies
| Entry point | Raw | Gzip | Peer deps |
|---|---|---|---|
@macrulez/vue-image-kit ESM | 45.6 kB | 14.08 kB | vue ^3.0 |
@macrulez/vue-image-kit CJS | 34.4 kB | 12.36 kB | vue ^3.0 |
@macrulez/vue-image-kit/cdn ESM | 11.2 kB | 2.53 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. The CLI, Vite plugin, Nuxt module, and self-hosted server are Node-only tooling — never bundled for the browser, so they're outside the scope of this table.
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:
// Only vLazyImg and its IO logic is included in the bundle.
// VImage, useBlurhash, decodeBlurhash are not imported → not bundled.
import { vLazyImg } from '@macrulez/vue-image-kit'
app.directive('lazy-img', vLazyImg)License
MIT