@macrulez/vue-image-kit
v1.1.0
Published
Vue 3 image optimization — lazy loading, WebP/AVIF, srcset, Blurhash/LQIP placeholders, CDN adapters and a CLI, with zero runtime dependencies.
Maintainers
Readme
A complete image optimization toolkit for Vue 3. One <VImage> component handles lazy loading, WebP/AVIF format switching, responsive art direction, Blurhash and LQIP placeholders, automatic srcset generation, error retry with exponential backoff, and smooth CSS transitions — with zero external runtime dependencies and a small, tree-shakeable footprint (see Bundle size & peer dependencies).
Everything you need beyond the component is included: a CLI that processes images at build time (resize, convert, generate LQIP and BlurHash, write a TypeScript manifest), CDN URL builders for 12 providers (Cloudinary, imgix, Bunny, Sanity, Storyblok, Contentful, Vercel, Cloudflare, ImageKit, TwicPics, Netlify, Gumlet) with hostname auto-detection, a Nuxt 3 module with auto-imports, a Vite plugin (including on-demand dev serving), a self-hosted on-demand image server for when there's no CDN, and headless composables for fully custom markup.
Fully typed with TypeScript. Tree-shakeable (sideEffects: false). SSR-safe — renders a native <img loading="lazy"> on the server, activates IntersectionObserver and canvas after hydration.
Contents
- Features
- Installation
- Quick start — Vue 3
- Quick start — Nuxt 3
- VImage
- useImage
- vLazyImg
- ThumbHash placeholder
- Blurhash placeholder
- LQIP — base64 preview
- srcset + sizes
- WebP / AVIF source switching
- Responsive sources — art direction
- Error state & fallback slot
- Lazy loading
- Vue plugin
- TypeScript types
- SSR compatibility
- Architecture
- CLI — generate images
- Incremental generation
- CDN adapters
- buildSizes helper
- generatePreloadLink
- useImagePreloader
- fetchpriority & decoding
- Error retry
- Network-aware loading
- Layout presets
- Nuxt module
- Vite plugin
- Self-hosted on-demand server
- Demo
- Bundle size & peer dependencies
Features
Placeholders
- Blurhash placeholder — custom in-house decoder (no external packages); renders to
<canvas>inonMounted; SSR renders a sized<div>preserving aspect-ratio - ThumbHash placeholder —
thumbhashprop on VImage auto-decodes to PNG data URL; supports alpha channel; better quality than BlurHash;--thumbhashflag in CLI generates hashes at build time - LQIP blur-up —
data:image/…;base64,…string asplaceholder; blurred preview withfilter: blur(); cross-fades via CSSopacitytransition - Average-color placeholder —
placeholderMode="color"derives a solid background color from the ThumbHash header (0 bytes, no canvas); or setplaceholderColordirectly - Shimmer placeholder —
placeholderMode="shimmer"shows an animated CSS skeleton (no hash needed); respectsprefers-reduced-motion - Client-side encoders —
encodeThumbHash()/encodeBlurhash()produce a hash from aFile/Canvas/ImageDatain the browser, for instant UGC previews; dependency-free
Component — VImage
- srcset autogeneration — pass
widths: [400, 800, 1200];srcsetstring built automatically;sizesprop passed through - Density descriptors —
densities: [1, 2, 3](reusesrc) or{ 1: …, 2: … }(distinct files per density) for1x/2x/3xsrcset on fixed-size images - Focal point —
focal: { x, y }maps toobject-positionso the subject stays in frame whenfit="cover"crops - WebP / AVIF switching —
srcas{ avif?, webp?, fallback }renders<picture>with typed<source>elements - Responsive art direction — named breakpoints map to
<source media="...">elements;max-widthandmin-widthqueries sorted correctly fetchpriorityprop —highfor LCP images,lowfor below-the-fold; maps to the native HTML attributedecodingprop —async(default) /sync/auto; passed directly to<img>- Error retry —
maxRetriesprop with exponential backoff; automatically retries failed loads without manual intervention - Error state —
#errorslot for custom fallback UI; built-in default (grey rectangle + icon);@errorevent
Loading
- IntersectionObserver lazy loading — IO instead of
loading="lazy"for precise control; configurablerootMarginandthreshold; SSR-safe - IO pooling — components sharing the same
rootMargin+thresholdconfig share oneIntersectionObserverinstance; no overhead at 50+ images - Background-image directive —
v-lazy-imgsetsbackground-imageon any element after viewport entry; LQIP placeholder; configurabletransition;onLoad/onErrorcallbacks useBackgroundImage()— composable for lazy + responsive (image-set()) backgrounds with blur-up; thesrcsetcapabilityv-lazy-imglacks
Composables & utilities
useImage()— headless state machine (idle → loading → loaded | error) + computedimgAttrs; works with any markupuseImagePreloader()— preload a batch of URLs before navigation;{ loaded, total, progress, isComplete, errors }buildSizes()— buildsizesattribute from breakpoint-keyed object; integrates with plugin breakpointsgeneratePreloadLink()— generates<link rel="preload" as="image">HTML for SSR/NuxtuseHead
CDN adapters — vue-image-kit/cdn
- Zero-dependency URL builders for Cloudinary, imgix, Bunny CDN, Sanity, Storyblok, Contentful, Vercel, Cloudflare Images, ImageKit.io, TwicPics, Netlify Image CDN, Gumlet
- Unified
.url(path, options)/.srcset(path, widths)interface across all providers autoLoader()— detects 8 of the 12 providers straight from a URL's hostname, no per-image adapter wiring; unrecognized hosts pass through unchanged
CLI — npx vue-image-kit generate
- Resize images to multiple widths, convert to WebP/AVIF, generate LQIP base64, encode BlurHash
- Write a TypeScript manifest (
images.ts) with all metadata pre-computed --watchmode,--dry-run,--skip-existing,--concurrency; config viavue-image-kit.config.jssharpas optional peer dependency — not included in the browser bundle
Ecosystem
- Nuxt module —
vue-image-kit/nuxt; auto-registers<VImage>andv-lazy-img; auto-imports all composables and utilities; breakpoints viaruntimeConfig - Vite plugin —
vue-image-kit/vite; runs the CLI processor onbuildStart; re-runs inhandleHotUpdateduring dev; build-time imports via?vik/?thumbhashquery suffixes - Vue plugin —
app.use(VImageKitPlugin, { breakpoints })registers component and directive globally - Zero external runtime dependencies — only Vue 3 as peer dep; full ESM + CJS, tree-shakeable,
sideEffects: false(see Bundle size & peer dependencies)
Installation
npm install vue-image-kitPeer dependency:
npm install vue@>=3.0Quick start — Vue 3
1. Register the plugin
// main.ts
import { createApp } from 'vue'
import { VImageKitPlugin } from 'vue-image-kit'
import App from './App.vue'
const app = createApp(App)
app.use(VImageKitPlugin)
app.mount('#app')2. Use the component
<template>
<VImage
src="/photo.jpg"
alt="Mountain landscape"
:width="1200"
:height="600"
blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
/>
</template><VImage> is registered globally by the plugin. No import needed.
3. Or import explicitly
<script setup lang="ts">
import { VImage } from 'vue-image-kit'
</script>
<template>
<VImage src="/photo.jpg" alt="My photo" />
</template>Quick start — Nuxt 3
1. Add the module to nuxt.config.ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['vue-image-kit/nuxt'],
vueImageKit: {
breakpoints: {
sm: '(max-width: 640px)',
md: '(max-width: 1024px)',
},
},
})2. Use in pages and components — everything is auto-imported
<template>
<VImage
:src="{ avif: '/hero.avif', webp: '/hero.webp', fallback: '/hero.jpg' }"
alt="Hero image"
:width="1920"
:height="1080"
:widths="[640, 1024, 1920]"
sizes="100vw"
blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
:lazy="true"
/>
</template><VImage>, v-lazy-img, and all composables are registered automatically — no imports needed. Canvas and IntersectionObserver are activated only on the client — no hydration mismatch.
VImage
The main component. Combines lazy loading, placeholder, format switching, and transitions in one element.
<VImage
src="/photo.jpg"
alt="Описание"
:width="1200"
:height="600"
blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
placeholder="data:image/jpeg;base64,..."
:widths="[400, 800, 1200]"
sizes="(max-width: 768px) 100vw, 50vw"
:lazy="true"
root-margin="300px"
fit="cover"
@load="onLoad"
@error="onError"
>
<template #error>
<div class="my-error">Image failed to load</div>
</template>
</VImage>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string \| SrcSet | — | URL or object with format variants. Optional when image is given |
| image | ImageMeta | — | Build-time metadata (a CLI manifest entry or ?vik import) — seeds src/width/height/blurhash/thumbhash/placeholder/sizes. Any explicit prop above overrides the matching field |
| alt | string | — | Required. alt attribute on the <img>. In dev builds, a suspicious value (missing, whitespace-only, or filename-shaped like "photo.jpg") logs a console.warn — a deliberate alt="" for a decorative image never triggers it |
| width | number | — | Intrinsic width; used to reserve aspect-ratio space |
| height | number | — | Intrinsic height; used to reserve aspect-ratio space |
| blurhash | string | — | BlurHash string; decoded to canvas in onMounted |
| thumbhash | string | — | ThumbHash string; decoded to PNG data URL, used as blur-up placeholder |
| placeholder | string | — | Base64 LQIP or ThumbHash data URL; overrides thumbhash if both provided |
| placeholderMode | 'blur' \| 'color' \| 'shimmer' | 'blur' | 'color' shows a solid average color (from thumbhash); 'shimmer' shows an animated skeleton (no hash needed) |
| placeholderColor | string | — | Explicit solid CSS color placeholder; takes precedence and needs no decode |
| widths | number[] | — | Pixel widths for automatic width-based (w) srcset generation |
| densities | number[] \| Record<number, string> | — | Density descriptors (1x/2x/3x) for fixed-size images. List reuses src; map gives a distinct file per density. Takes precedence over widths, ignores sizes |
| sizes | string | — | sizes attribute passed to <img> (width-based srcset only) |
| breakpoints | BreakpointMap | — | Local breakpoints (merged with global plugin breakpoints) |
| sources | ResponsiveSrc | — | Breakpoint-key → URL (or { avif?, webp?, fallback }) map for art direction, optionally combined with format switching per breakpoint |
| lazy | boolean | true | Enable IntersectionObserver lazy loading |
| rootMargin | string | "200px" | IO rootMargin — how far before the viewport loading starts |
| threshold | number | 0 | IO threshold — intersection ratio required to trigger |
| fit | ObjectFit | "cover" | CSS object-fit value on the <img> |
| focal | FocalPoint | — | Focal point { x, y } (fractions 0–1) → object-position; keeps the subject in frame when fit="cover" crops |
| maxRetries | number | 0 | Max retry attempts on load failure |
| retryDelay | number | 1000 | Initial delay in ms; doubles each retry (exponential backoff) |
| fetchpriority | 'high' \| 'low' \| 'auto' | — | Browser fetch priority hint |
| decoding | 'async' \| 'sync' \| 'auto' | 'async' | Image decoding mode |
| priority | boolean | false | Shorthand for the LCP/hero image — forces lazy=false, fetchpriority='high', decoding='sync'. Not automatic LCP detection (that isn't reliable pre-paint); mark the one image that matters instead of setting three props by hand |
| respectSaveData | boolean | false | On a save-data connection: neutralizes priority (stays lazy) and downgrades src to the smallest URL available from densities/image.srcset. See "Network-aware loading" |
| layout | 'fixed' \| 'responsive' \| 'fill' | — | Wrapper sizing preset. Unset keeps the current default (fills container width, aspect-ratio preserved). See "Layout presets" |
| cdn | boolean \| AutoLoaderConfig | — | Opt-in: routes a string src through autoLoader() — detects the CDN from the URL and rewrites it, no manual adapter wiring. See "CDN adapters → Auto CDN detection with VImage" |
| loader | 'server' | — | Opt-in: routes a string src through the vue-image-kit/server on-demand handler via buildImageUrl(). See "Self-hosted on-demand server → Wiring VImage to it" |
| loaderRoute | string | /_vik/image | Route override for loader="server" — takes precedence over the plugin/Nuxt-module serverRoute default |
Events
| Event | Payload | Description |
|---|---|---|
| @load | Event | Fired when the image finishes loading |
| @error | Event | Fired when the image fails to load |
Slots
| Slot | Description |
|---|---|
| #error | Custom UI shown when the image fails to load. If omitted, a grey rectangle with a broken-image icon is shown. |
Examples
Simple image with lazy loading:
<VImage src="/photo.jpg" alt="Landscape" />With blurhash and dimensions for aspect-ratio reservation:
<VImage
src="/photo.jpg"
alt="Landscape"
:width="1200"
:height="800"
blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
/>WebP/AVIF with srcset and blur-up:
<VImage
:src="{ avif: '/photo.avif', webp: '/photo.webp', fallback: '/photo.jpg' }"
alt="Product"
:width="800"
:height="600"
placeholder="data:image/jpeg;base64,/9j/4AAQSkZJRgAB..."
:widths="[400, 800]"
sizes="(max-width: 640px) 100vw, 800px"
/>Disable lazy loading for above-the-fold images:
<VImage src="/hero.jpg" alt="Hero" :lazy="false" />LCP/hero image — priority instead of three separate props:
<VImage src="/hero.jpg" alt="Hero" :width="1600" :height="900" priority />From CLI/manifest output — no manual prop wiring:
<script setup lang="ts">
import meta from './photo.jpg?vik' // or: import { images } from './assets/images'
</script>
<template>
<!-- src/width/height/blurhash/thumbhash/placeholder/sizes all come from meta -->
<VImage :image="meta" alt="Product photo" />
</template>Focal point — keep the subject in frame when cropping:
<!-- With fit="cover" the image is cropped to the box; focal decides which
part survives. { x: 0.5, y: 0.3 } favours the upper-middle (e.g. a face). -->
<VImage
src="/portrait.jpg"
alt="Team member"
:width="400"
:height="400"
fit="cover"
:focal="{ x: 0.5, y: 0.3 }"
/>Cheapest placeholder — a solid average color (no canvas, 0 bytes):
<!-- 'color' mode pulls the average RGBA straight from the ThumbHash header. -->
<VImage
src="/photo.jpg"
alt="Gallery item"
:width="600"
:height="400"
thumbhash="3OcRJYB4d3h/iIeHeEh3eIhw+j5n"
placeholder-mode="color"
/>
<!-- Or an explicit color you already know — needs no ThumbHash at all. -->
<VImage src="/photo.jpg" alt="Banner" placeholder-color="#1e3a8a" />Animated skeleton — when you have no hash at all:
<!-- A CSS shimmer sweep until the image loads. Respects prefers-reduced-motion. -->
<VImage src="/photo.jpg" alt="Card" :width="400" :height="300" placeholder-mode="shimmer" />Custom error slot:
<VImage src="/missing.jpg" alt="Missing">
<template #error>
<div class="placeholder">
<span>📷</span>
<p>Image unavailable</p>
</div>
</template>
</VImage>Handling events:
<script setup lang="ts">
function onLoad(e: Event) {
console.log('Image loaded', e)
}
function onError(e: Event) {
console.warn('Image failed', e)
}
</script>
<template>
<VImage
src="/photo.jpg"
alt="Photo"
@load="onLoad"
@error="onError"
/>
</template>useImage
Headless composable. Use it when you need the loading state machine and computed attributes but want to render your own markup.
const {
status, // Ref<'idle' | 'loading' | 'loaded' | 'error'>
isLoaded, // ComputedRef<boolean>
isError, // ComputedRef<boolean>
imgAttrs, // ComputedRef<ImgAttrs> — ready to spread onto <img>
observe, // (el: Ref<HTMLElement | null>) => void
onImgLoad, // () => void — call from img @load
onImgError, // () => void — call from img @error
} = useImage(options)Options
| Option | Type | Default | Description |
|---|---|---|---|
| src | string \| SrcSet | — | Image URL or format object |
| widths | number[] | [] | Widths for width-based (w) srcset generation |
| densities | number[] \| Record<number, string> | — | Density descriptors (1x/2x/3x); list reuses src, map gives distinct files; takes precedence over widths, ignores sizes |
| sizes | string | — | sizes attribute value (width-based srcset only) |
| lazy | boolean | true | Enable IntersectionObserver |
| rootMargin | string | "200px" | IO rootMargin |
| threshold | number | 0 | IO threshold |
| fit | ObjectFit | "cover" | object-fit style |
| maxRetries | number | 0 | Max retry attempts on load failure |
| retryDelay | number | 1000 | Initial delay in ms; doubles each retry |
State machine
idle → loading → loaded
→ error- When
lazy: true— transitions toloadingwhen the observed element enters the viewport - When
lazy: false— transitions toloadingimmediately afteronMounted
Return value
| Property | Type | Description |
|---|---|---|
| status | Ref<ImageStatus> | Current loading state |
| isLoaded | ComputedRef<boolean> | true when status === 'loaded' |
| isError | ComputedRef<boolean> | true when status === 'error' |
| imgAttrs | ComputedRef<object> | { src, srcset?, sizes?, style } — ready for v-bind |
| observe | Function | Pass a Ref<HTMLElement> to start watching for intersection |
| onImgLoad | Function | Call from <img @load> to advance to loaded |
| onImgError | Function | Call from <img @error> to advance to error |
Example — custom render
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useImage } from 'vue-image-kit'
const containerRef = ref<HTMLElement | null>(null)
const { status, isLoaded, imgAttrs, observe, onImgLoad, onImgError } = useImage({
src: '/photo.jpg',
widths: [400, 800, 1200],
sizes: '(max-width: 768px) 100vw, 50vw',
})
onMounted(() => {
observe(containerRef)
})
</script>
<template>
<div ref="containerRef" class="image-wrapper">
<div v-if="status === 'idle'" class="skeleton" />
<img
v-if="status === 'loading' || isLoaded"
v-bind="imgAttrs"
alt="Photo"
:class="{ visible: isLoaded }"
@load="onImgLoad"
@error="onImgError"
/>
<div v-if="status === 'error'" class="error-state">
Failed to load
</div>
</div>
</template>
<style scoped>
img { opacity: 0; transition: opacity 0.3s; }
img.visible { opacity: 1; }
</style>vLazyImg
Directive for setting background-image on any element after it enters the viewport. Use it when you can't use the <VImage> component — CSS backgrounds, third-party wrappers, etc.
<!-- Simple string -->
<div v-lazy-img="'/background.jpg'" class="hero" />
<!-- Object with options -->
<div
v-lazy-img="{
src: '/background.jpg',
placeholder: 'data:image/jpeg;base64,...',
rootMargin: '100px',
onLoad: () => console.log('loaded'),
onError: (e) => console.error(e),
}"
class="hero"
/>Options
| Option | Type | Default | Description |
|---|---|---|---|
| src | string | — | URL of the background image |
| placeholder | string | — | Base64 or URL shown immediately; replaced on load |
| rootMargin | string | "200px" | IO rootMargin |
| threshold | number | 0 | IO threshold |
| onLoad | () => void | — | Called when the image finishes loading |
| onError | (e: Event) => void | — | Called when the image fails to load |
Behaviour
- On mount — creates an
IntersectionObserverand starts watching the element - When the element enters the viewport — if
placeholderis set it is applied immediately asbackground-image - A new
Imageobject loadssrcin the background - On load —
background-imageis updated tosrc;onLoadis called - On error —
onErroris called;background-imagestays as the placeholder (if any) - On unmount — the observer is disconnected
- On binding update — the observer is recreated with the new options
Registering the directive manually
The directive is registered automatically with VImageKitPlugin. To register it in a single component:
<script setup lang="ts">
import { vLazyImg } from 'vue-image-kit'
</script>
<template>
<div v-lazy-img="'/bg.jpg'" style="width:100%;height:400px" />
</template>Or globally without the plugin:
import { vLazyImg } from 'vue-image-kit'
app.directive('lazy-img', vLazyImg)Example — card with lazy background
<script setup lang="ts">
import { vLazyImg } from 'vue-image-kit'
const cards = [
{ id: 1, bg: '/card-1.jpg', placeholder: 'data:image/jpeg;base64,/9j/...' },
{ id: 2, bg: '/card-2.jpg', placeholder: 'data:image/jpeg;base64,/9j/...' },
]
</script>
<template>
<div
v-for="card in cards"
:key="card.id"
v-lazy-img="{ src: card.bg, placeholder: card.placeholder }"
class="card"
/>
</template>
<style scoped>
.card {
width: 300px;
height: 200px;
background-size: cover;
background-position: center;
border-radius: 12px;
}
</style>useBackgroundImage
The v-lazy-img directive lazy-loads a background but can't do srcset. useBackgroundImage is the composable counterpart: lazy loading + responsive image-set() (the CSS-native equivalent of srcset) + blur-up — returned as a reactive :style you bind yourself.
<script setup lang="ts">
import { useBackgroundImage } from 'vue-image-kit'
const { target, style, isLoaded } = useBackgroundImage('/hero.jpg', {
placeholder: 'data:image/jpeg;base64,/9j/...',
densities: [1, 2], // → image-set(url("/hero.jpg") 1x, url("/hero.jpg") 2x)
rootMargin: '300px',
})
</script>
<template>
<section ref="target" :style="style" class="hero">
<h1 v-show="isLoaded">Welcome</h1>
</section>
</template>
<style scoped>
.hero { width: 100%; height: 60vh; }
</style>Options
| Option | Type | Default | Description |
|---|---|---|---|
| placeholder | string | — | URL/data URL shown (blurred) until the full image loads |
| densities | number[] | — | Builds a responsive image-set() with 1x/2x/… entries |
| type | string | — | MIME hint for image-set() entries (e.g. 'image/webp') |
| lazy | boolean | true | Gate loading behind IntersectionObserver |
| rootMargin | string | '200px' | IO root margin |
| threshold | number | 0 | IO threshold |
| transition | string | '0.4s ease' | Blur-up transition |
| backgroundSize | string | 'cover' | background-size |
| backgroundPosition | string | 'center' | background-position |
Returns { target, style, status, isLoaded, isLoading, load }. Attach target via a template ref and bind style; call load() to trigger manually when lazy: false. SSR-safe (loading is deferred to the client).
ThumbHash placeholder
ThumbHash is a modern alternative to BlurHash with alpha channel support, better visual quality on photos, and a shorter hash string. It decodes to a PNG data URL.
thumbhash prop — the simplest way:
<VImage
src="/photo.png"
alt="Photo with transparency"
thumbhash="3OcRJYB4d3h/iIeHeEh3eIhw+j5n"
/>VImage decodes the hash automatically and uses it as a blur-up placeholder. No manual decoding needed.
Using the decoder directly (for custom markup or v-lazy-img):
import { decodeThumbHash } from 'vue-image-kit'
const dataUrl = decodeThumbHash('3OcRJYB4d3h/iIeHeEh3eIhw+j5n')
// → 'data:image/png;base64,...'Average color — the cheapest placeholder of all (decoded from the header, no pixels):
import { thumbHashToAverageRGBA, thumbHashToAverageColor } from 'vue-image-kit'
thumbHashToAverageRGBA('3OcRJYB4d3h/iIeHeEh3eIhw+j5n')
// → { r, g, b, a } (each channel 0–1)
thumbHashToAverageColor('3OcRJYB4d3h/iIeHeEh3eIhw+j5n')
// → 'rgba(150, 146, 104, 1.000)' — drop straight into background-colorOr let VImage do it via placeholder-mode="color" (see Props).
placeholder prop — equivalent when you already have the data URL:
<VImage
src="/photo.png"
alt="Photo"
:placeholder="decodeThumbHash('3OcRJYB4d3h/iIeHeEh3eIhw+j5n')"
/>If both thumbhash and placeholder are provided, placeholder takes priority.
Generating ThumbHash hashes at build time:
Use the CLI with --thumbhash flag (requires thumbhash as a dev dependency):
npm install thumbhash --save-dev
npx vue-image-kit generate \
--input ./src/images \
--manifest ./src/assets/images.ts \
--thumbhashThe manifest will include a thumbhash field for each image alongside blurhash and placeholder.
Or generate manually in Node.js:
import { rgbaToThumbHash } from 'thumbhash'
import sharp from 'sharp'
const { data, info } = await sharp('photo.jpg')
.resize(100, 100, { fit: 'inside' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
const hash = rgbaToThumbHash(info.width, info.height, new Uint8Array(data.buffer))
const hashBase64 = Buffer.from(hash).toString('base64')
// Store in DB / manifest, pass as thumbhash propBlurhash placeholder
<VImage> decodes the blurhash string internally — no external package needed. The decoder is implemented from scratch following the open blurhash specification.
Pass blurhash together with width and height to enable the canvas placeholder:
<VImage
src="/photo.jpg"
alt="Landscape"
:width="1200"
:height="800"
blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
/>How it works:
- On the server — a blank
<div>withaspect-ratio: 1200/800is rendered to reserve space - On mount —
decodeBlurhash(hash, width, height)is called and the pixel data is drawn to<canvas>viaImageData - The canvas stays visible while the image loads; it fades out via opacity transition when the image is ready
Using the decoder directly:
import { decodeBlurhash } from 'vue-image-kit'
const pixels = decodeBlurhash('LEHV6nWB2yk8pyo0adR*.7kCMdnj', 32, 32)
// pixels: Uint8ClampedArray<ArrayBuffer> — RGBA, row-major
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
canvas.getContext('2d')!.putImageData(new ImageData(pixels, 32, 32), 0, 0)Generating blurhash strings:
The decoder is included — you still need to generate hashes on the server/build step. Use the official blurhash package at build time, or any server-side tool. Pass the resulting string to <VImage> as the blurhash prop.
LQIP — base64 preview
LQIP (Low Quality Image Placeholder) shows a tiny blurred version of the image while the full resolution loads.
<VImage
src="/photo.jpg"
alt="Photo"
placeholder="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAA..."
/>How it works:
- The base64 image is rendered as a separate
<img>withfilter: blur(20px)andtransform: scale(1.05)(to hide blurred edges) - When the full image loads, both fade with an
opacitytransition — the placeholder fades out, the full image fades in - The placeholder is
aria-hidden="true"— invisible to screen readers
Generating LQIP at build time (Node.js example):
import sharp from 'sharp'
const buffer = await sharp('photo.jpg')
.resize(20)
.jpeg({ quality: 20 })
.toBuffer()
const lqip = `data:image/jpeg;base64,${buffer.toString('base64')}`
// Pass this string as the placeholder propClient-side encoding (user-generated content)
When a user uploads a photo, encode a placeholder in the browser so you can show a blur-up preview instantly — before the full image is uploaded or processed. Both encoders are dependency-free (the ThumbHash encoder is a faithful port of the reference, byte-identical to the thumbhash package) and accept a File/Blob, HTMLImageElement, HTMLCanvasElement, ImageBitmap, or ImageData.
import { encodeThumbHash, encodeBlurhash, decodeThumbHash } from 'vue-image-kit'
async function onFileSelected(file: File) {
const thumbhash = await encodeThumbHash(file)
// → base64 string; feed straight into <VImage :thumbhash="thumbhash">
// or decodeThumbHash(thumbhash) for a data URL preview.
const blurhash = await encodeBlurhash(file, { componentX: 4, componentY: 3 })
}| Function | Returns | Options |
|---|---|---|
| encodeThumbHash(source, options?) | Promise<string> (base64) | maxSize (default/max 100) |
| encodeBlurhash(source, options?) | Promise<string> | componentX (1–9, default 4), componentY (1–9, default 3), maxSize (default 64) |
The source is downscaled to maxSize on its longest edge before encoding (a ThumbHash must fit within 100×100). These require a browser/DOM — they throw in SSR.
<script setup lang="ts">
import { ref } from 'vue'
import { encodeThumbHash } from 'vue-image-kit'
const hash = ref('')
async function handleUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) hash.value = await encodeThumbHash(file)
}
</script>
<template>
<input type="file" accept="image/*" @change="handleUpload" />
<VImage v-if="hash" :src="previewUrl" alt="Preview" :thumbhash="hash" />
</template>srcset + sizes
Pass widths to auto-generate the srcset attribute:
<VImage
src="/photo.jpg"
alt="Photo"
:widths="[400, 800, 1200]"
sizes="(max-width: 768px) 100vw, 50vw"
/>Renders:
<img
src="/photo.jpg"
srcset="/photo.jpg 400w, /photo.jpg 800w, /photo.jpg 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Photo"
/>When widths is not provided, srcset is not added — the plain src is used.
When widths is provided but sizes is not, sizes defaults to "100vw".
Density descriptors (1x / 2x / 3x)
For fixed-size images — icons, avatars, logos — use densities instead of widths. The browser picks the candidate matching the device pixel ratio; no sizes is needed. densities takes precedence over widths (the two descriptor types can't be mixed in one srcset).
:densities accepts two forms:
<!-- 1. Per-density URL map — distinct files (recommended for static assets). -->
<VImage
src="/avatar.png"
alt="Avatar"
:width="48"
:height="48"
:densities="{ 1: '/avatar.png', 2: '/[email protected]', 3: '/[email protected]' }"
/>
<!-- → srcset="/avatar.png 1x, /[email protected] 2x, /[email protected] 3x" -->
<!-- 2. Density list — reuses the single `src` for every density. Only useful
when the URL itself is resolution-aware (a CDN/DPR endpoint). -->
<VImage src="https://cdn.example.com/avatar?dpr=auto" alt="Avatar" :densities="[1, 2, 3]" />
<!-- → srcset="…?dpr=auto 1x, …?dpr=auto 2x, …?dpr=auto 3x" -->Using the utilities directly:
import { generateSrcset, generateSizes, generateDensitySrcset } from 'vue-image-kit'
generateSrcset('/photo.jpg', [400, 800, 1200])
// → '/photo.jpg 400w, /photo.jpg 800w, /photo.jpg 1200w'
generateSizes('(max-width: 768px) 100vw, 50vw')
// → '(max-width: 768px) 100vw, 50vw'
generateSizes()
// → '100vw'
generateDensitySrcset('/logo.png', [1, 2, 3])
// → '/logo.png 1x, /logo.png 2x, /logo.png 3x'
// Distinct files per density via a URL map:
generateDensitySrcset({ 1: '/a.png', 2: '/[email protected]' }, [1, 2])
// → '/a.png 1x, /[email protected] 2x'WebP / AVIF source switching
When src is an object instead of a string, <VImage> renders a <picture> element with the appropriate <source> elements:
<VImage
:src="{
avif: '/photo.avif',
webp: '/photo.webp',
fallback: '/photo.jpg',
}"
alt="Photo"
:width="1200"
:height="800"
/>Renders:
<picture>
<source srcset="/photo.avif" type="image/avif" />
<source srcset="/photo.webp" type="image/webp" />
<img src="/photo.jpg" alt="Photo" width="1200" height="800" />
</picture>The browser picks the first format it supports. If only webp is provided, only one <source> is added. fallback is always required.
SrcSet object
interface SrcSet {
avif?: string // URL of the AVIF version
webp?: string // URL of the WebP version
fallback: string // Required — the original format (JPEG/PNG)
}Responsive sources — art direction
Use this when you need to serve a fundamentally different image (different crop, different composition) based on screen size. Implemented via named breakpoints — the browser picks the first matching <source media="...">.
Global breakpoints (set once when installing the plugin)
// main.ts
app.use(VImageKitPlugin, {
breakpoints: {
sm: '(max-width: 640px)',
md: '(max-width: 1024px)',
lg: '(min-width: 1025px)',
},
})Using in components — keys only
<VImage
src="/hero-desktop.jpg"
alt="Hero"
:sources="{
sm: '/hero-mobile.jpg',
md: '/hero-tablet.jpg',
}"
/>Generates:
<picture>
<source media="(max-width: 640px)" srcset="/hero-mobile.jpg" />
<source media="(max-width: 1024px)" srcset="/hero-tablet.jpg" />
<img src="/hero-desktop.jpg" alt="Hero" />
</picture><source> order is set automatically in ascending max-width order — required by <picture>, which picks the first matching source.
Per-component breakpoints
Merged with global breakpoints. Local keys take priority on conflict:
<VImage
src="/product-desktop.jpg"
alt="Product"
:breakpoints="{
xs: '(max-width: 375px)',
wide: '(min-width: 1600px)',
}"
:sources="{
xs: '/product-xs.jpg',
sm: '/product-mobile.jpg',
md: '/product-tablet.jpg',
wide: '/product-wide.jpg',
}"
/>The resulting <picture> contains <source> elements for xs, sm, md (from merged breakpoints), and wide — sorted automatically.
Combining with AVIF/WebP
Responsive sources (sources) and format sources (src as object) are independent and rendered together:
<VImage
:src="{ avif: '/hero.avif', webp: '/hero.webp', fallback: '/hero.jpg' }"
:sources="{ sm: '/hero-mobile.jpg' }"
alt="Hero"
/><picture>
<source media="(max-width: 640px)" srcset="/hero-mobile.jpg" />
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero" />
</picture>That covers "one crop set + one format set, independent of each other." For a
different crop and different formats per breakpoint — e.g. a portrait
AVIF/WebP crop on mobile, a landscape AVIF/WebP crop on desktop — a breakpoint's
value in sources can itself be a { avif?, webp?, fallback } object instead
of a plain URL:
<VImage
alt="Hero"
:sources="{
sm: { avif: '/hero-mobile.avif', webp: '/hero-mobile.webp', fallback: '/hero-mobile.jpg' },
md: { webp: '/hero-tablet.webp', fallback: '/hero-tablet.jpg' },
}"
src="/hero-desktop.jpg"
/><picture>
<source media="(max-width: 640px)" srcset="/hero-mobile.avif" type="image/avif" />
<source media="(max-width: 640px)" srcset="/hero-mobile.webp" type="image/webp" />
<source media="(max-width: 640px)" srcset="/hero-mobile.jpg" />
<source media="(max-width: 1024px)" srcset="/hero-tablet.webp" type="image/webp" />
<source media="(max-width: 1024px)" srcset="/hero-tablet.jpg" />
<img src="/hero-desktop.jpg" alt="Hero" />
</picture>Plain-URL and format-object breakpoints can be mixed freely in the same
sources object. avif/webp are both optional per breakpoint — only the
formats you actually have are emitted.
BreakpointMap
type BreakpointMap = Record<string, string>
// key — arbitrary name, value — CSS media queryBreakpoint priority
| Source | Priority |
|---|---|
| Local breakpoints prop on the component | High — overrides global keys on conflict |
| Global breakpoints from VImageKitPlugin | Base — available in all components |
Error state & fallback slot
Default fallback — if no #error slot is provided, a grey rectangle with a broken-image SVG icon is shown:
<VImage src="/missing.jpg" alt="Missing" :width="400" :height="300" />
<!-- Shows: grey rectangle + SVG icon -->Custom fallback via slot:
<VImage src="/missing.jpg" alt="Missing" :width="400" :height="300">
<template #error>
<div class="error-placeholder">
<img src="/no-image.svg" alt="" />
<p>Image is currently unavailable</p>
</div>
</template>
</VImage>Handling errors in JavaScript:
<script setup lang="ts">
function handleError(e: Event) {
console.error('Image failed to load:', e)
// Report to Sentry, switch to a fallback URL, etc.
}
</script>
<template>
<VImage src="/photo.jpg" alt="Photo" @error="handleError" />
</template>Lazy loading
<VImage> uses IntersectionObserver for lazy loading — not the native loading="lazy" attribute — for full control over when loading starts.
<!-- Default: loads when the image is 200px from the viewport -->
<VImage src="/photo.jpg" alt="Photo" />
<!-- Custom rootMargin — start loading 500px before the viewport -->
<VImage src="/photo.jpg" alt="Photo" root-margin="500px" />
<!-- Load when 50% of the image is visible -->
<VImage src="/photo.jpg" alt="Photo" :threshold="0.5" />
<!-- Disable lazy loading — load immediately (above the fold) -->
<VImage src="/photo.jpg" alt="Photo" :lazy="false" />How it works
- On mount — an
IntersectionObserveris created and begins watching the wrapper element - When the element enters the viewport (accounting for
rootMargin) — the imagesrcis set and loading begins (status: 'loading') - When the image loads —
statustransitions to'loaded'; the placeholder fades out - The observer disconnects after the first intersection — no unnecessary callbacks
SSR behaviour
On the server, IntersectionObserver is unavailable. <VImage> renders a plain <img loading="lazy"> without any JavaScript-driven state. After hydration, onMounted sets up the IO as normal.
Vue plugin
Register <VImage> and v-lazy-img globally with a single app.use() call:
import { createApp } from 'vue'
import { VImageKitPlugin } from 'vue-image-kit'
import App from './App.vue'
const app = createApp(App)
app.use(VImageKitPlugin)
app.mount('#app')After installation:
<VImage>is available in all templates without importingv-lazy-imgdirective is registered and available in all templates
Import the plugin and individual exports separately if needed:
import {
VImageKitPlugin, // Vue plugin
VImage, // component
vLazyImg, // directive
useImage, // composable
useBlurhash, // canvas composable
useLazyLoad, // IO composable
decodeBlurhash, // standalone decoder
generateSrcset, // srcset utility
generateSizes, // sizes utility
} from 'vue-image-kit'TypeScript types
All public types are exported from the package root:
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
type ImageStatus = 'idle' | 'loading' | 'loaded' | 'error'The state machine transitions in order: idle → loading → loaded or idle → loading → error.
SrcSet
interface SrcSet {
avif?: string // Optional AVIF source URL
webp?: string // Optional WebP source URL
fallback: string // Required — used as the <img src> fallback
}LazyImgOptions
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
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'),
}<div v-lazy-img="bgOptions" class="hero" />SSR compatibility
| Scenario | Behaviour |
|---|---|
| Server render — <VImage> | Renders <img loading="lazy"> with src and alt; no IO, no canvas |
| Server render — aspect-ratio | A <div> with aspect-ratio: width/height is rendered when width and height are provided |
| Blurhash on server | Canvas code is inside onMounted — not executed; a blank container is rendered instead |
| IntersectionObserver on server | Not used; the server renders a plain <img> |
| Hydration | After mount, onMounted sets up IO (if lazy: true) or immediately starts loading (if lazy: false) |
| v-lazy-img on server | Directive hooks (mounted, unmounted) are not called during SSR — no IO is created |
| useLazyLoad on 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, 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) → stringCLI — generate images
Resize images, convert to WebP/AVIF, generate LQIP and BlurHash, write a TypeScript manifest — all in one command.
Requires sharp as a dev dependency:
npm install sharp --save-devBasic usage:
npx vue-image-kit generate \
--input ./src/images \
--output ./public/images \
--widths 400,800,1200 \
--formats jpg,webp,avif \
--manifest ./src/assets/images.tsPrints a per-image report as it works — source path/format/dimensions/size,
then every output variant with its own path/format/dimensions/size ((existing)
for a file --skip-existing kept, (dry-run — not written) under --dry-run)
— followed by a batch total: images/files processed, total input vs. output
size, and how much the smallest available format saves vs. the original on
average (deliberately not "total output vs. total input" — with several
widths × formats generated per image, total output is naturally many times
one original's size, which would misleadingly read as "this made it worse"):
[vue-image-kit] Processing 1 image(s)…
[vue-image-kit] photo1
Input ./src/images/photo1.jpg
jpg · 1200×800 · 245.3 KB
Output
./public/images/photo1-400.jpg jpg 400×267 52.1 KB
./public/images/photo1-800.jpg jpg 800×533 118.4 KB
./public/images/photo1.jpg jpg 1200×800 198.2 KB
./public/images/photo1.webp webp 1200×800 112.9 KB
./public/images/photo1.avif avif 1200×800 79.6 KB
[vue-image-kit] Done. 1 image(s) → 5 file(s).
Input: 1 image(s), 245.3 KB total
Output: 5 file(s), 561.2 KB total
Smallest available format saves ~79% vs. original, on average
[vue-image-kit] Manifest written to ./src/assets/images.tsAll options:
| Flag | Default | Description |
|---|---|---|
| --input <dir> | ./src/images | Source directory |
| --output <dir> | ./public/images | Output directory |
| --widths <list> | 400,800,1200 | Comma-separated output widths |
| --formats <list> | jpg,webp,avif | Output formats |
| --quality <json> | {"jpg":85,"webp":80,"avif":65} | Quality per format |
| --template <str> | {name}-{width}.{ext} | Filename template ({name}, {width}, {ext}) |
| --manifest <path> | — | Write images.ts manifest to this path |
| --public-path <str> | /images | URL prefix used in manifest paths |
| --lqip / --no-lqip | enabled | Generate base64 LQIP placeholder |
| --blurhash / --no-blurhash | enabled | Generate BlurHash string |
| --thumbhash / --no-thumbhash | disabled | Generate ThumbHash string (requires thumbhash dev dep) |
| --clean | — | Remove output dir before generating |
| --dry-run | — | Preview without writing files |
| --skip-existing | — | Skip already-generated files |
| --concurrency <n> | 4 | Parallel workers |
| --watch | — | Watch input dir and regenerate on change |
| --incremental / --no-incremental | auto | Skip reprocessing a source whose mtime (or, if that changed, content hash) matches the last run. Auto-enabled under --watch (and by the Vite plugin during vite dev) unless set explicitly either way — a one-shot generate stays off by default. See "Incremental generation" below |
Config file — create vue-image-kit.config.js in your project root to avoid repeating flags:
// vue-image-kit.config.js
export default {
input: './photos',
output: './public/images',
widths: [480, 960, 1440],
formats: ['jpg', 'webp'],
manifest: './src/assets/images.ts',
publicPath: '/images',
}SVG and animated GIF are handled differently from raster formats — they're detected by input extension, not by --formats:
- SVG is copied through untouched (no rasterizing — it's already resolution-independent). The manifest entry gets
srcpointing at the copy;webp/avif/placeholder/blurhash/thumbhashare empty strings. - Animated GIF is copied through as the guaranteed-compatible fallback (
src), and — whenwebpis in--formats— re-encoded to animated WebP (webpfield) for a real size win. AVIF is skipped: animated-AVIF support acrosssharp/libavif builds is too inconsistent to promise. LQIP/BlurHash/ThumbHash placeholders are still generated from the first frame.
Incremental generation
--watch and the Vite plugin's buildStart/handleHotUpdate both call the
same generate() the CLI does — by default, every single-file change means
re-scanning and reprocessing every source image, not just the one that
changed. incremental mode fixes that:
npx vue-image-kit generate --watch --incremental # already the default under --watchFor each source, it checks a persisted record from the previous run:
mtime unchanged → skip entirely, no read, no sharp call. Mtime changed
(e.g. a git checkout touched every file) → falls back to a content hash
before deciding — an unmodified file survives a checkout without triggering
a needless reprocess. The record — a JSON manifest at
<output>/.vik-incremental.json — also stores each skipped image's full
metadata, so the batch report/--manifest output stays complete even for
images that weren't touched this run.
Changing widths/formats/quality/template/publicPath/lqip/
blurhash/thumbhash between runs invalidates everything at once
(logged as Config changed since last run) — not per-file diffing, since a
config change can affect any or all outputs. --clean also invalidates
everything, naturally: it deletes output, and the manifest lives inside
it. No effect under --dry-run (nothing is written, so there's nothing
valid to compare against next time).
Defaults: off for a one-shot generate (a single run gains nothing from
caching), on automatically under --watch and during vite dev (vite
build stays off — a production artifact shouldn't risk a stale cache).
An explicit --incremental/--no-incremental (CLI flag, config file, or
Vite plugin option) always overrides the automatic default either way.
CDN adapters
vue-image-kit/cdn provides URL builders for popular image CDNs — no dependencies, pure functions.
import {
cloudinary, imgix, bunny, sanity, storyblok, contentful, vercel,
cloudflare, imagekit, twicpics, netlify, gumlet,
} from 'vue-image-kit/cdn'All adapters share the same interface:
adapter.url(path, options?) // → single URL string
adapter.srcset(path, widths, options?) // → ready srcset stringCloudinary:
const cdn = cloudinary({ cloudName: 'my-cloud' })
cdn.url('photo.jpg', { width: 800, format: 'webp' })
// → https://res.cloudinary.com/my-cloud/w_800,q_auto,f_webp/image/upload/photo.jpg
cdn.srcset('photo.jpg', [400, 800, 1200])
// → 'https://res.cloudinary.com/my-cloud/w_400,... 400w, ...'imgix:
const cdn = imgix('https://mysite.imgix.net')
cdn.url('photo.jpg', { width: 800, dpr: 2 })
// → https://mysite.imgix.net/photo.jpg?w=800&dpr=2&auto=format
cdn.srcset('photo.jpg', [400, 800, 1200])Bunny CDN:
const cdn = bunny('https://myzone.b-cdn.net')
cdn.url('photo.jpg', { width: 800, format: 'webp', quality: 85 })Sanity:
const cdn = sanity({ projectId: 'abc123', dataset: 'production' })
cdn.url('image-abc123-800x600-jpg', { width: 400 })Storyblok:
const cdn = storyblok()
cdn.url('https://a.storyblok.com/f/12345/photo.jpg', { width: 800 })Contentful:
const cdn = contentful()
cdn.url('https://images.ctfassets.net/space/token/photo.jpg', { width: 800 })Vercel Image Optimization:
const cdn = vercel({ origin: 'https://myapp.vercel.app' })
cdn.url('/photo.jpg', { width: 800, quality: 75 })
// → https://myapp.vercel.app/_vercel/image?url=%2Fphoto.jpg&w=800&q=75Cloudflare Images:
const cdn = cloudflare('https://example.com')
cdn.url('/photo.jpg', { width: 800, format: 'webp' })
// → https://example.com/cdn-cgi/image/width=800,format=webp/photo.jpgImageKit.io:
const cdn = imagekit('https://ik.imagekit.io/your_id')
cdn.url('photo.jpg', { width: 800, format: 'webp' })
// → https://ik.imagekit.io/your_id/photo.jpg?tr=w-800,f-webpTwicPics:
const cdn = twicpics('https://demo.twic.pics')
cdn.url('photo.jpg', { width: 800, format: 'webp' })
// → https://demo.twic.pics/photo.jpg?twic=v1/resize=800/output=webpNetlify Image CDN:
const cdn = netlify({ origin: 'https://myapp.netlify.app' })
cdn.url('/photo.jpg', { width: 800, format: 'webp', quality: 75 })
// → https://myapp.netlify.app/.netlify/images?url=%2Fphoto.jpg&w=800&fm=webp&q=75Gumlet:
const cdn = gumlet('https://demo.gumlet.io')
cdn.url('photo.jpg', { width: 800, format: 'webp' })
// → https://demo.gumlet.io/photo.jpg?w=800&format=webpUse with VImage:
<script setup lang="ts">
import { cloudinary } from 'vue-image-kit/cdn'
const cdn = cloudinary({ cloudName: 'my-cloud' })
</script>
<template>
<VImage
src="/photo.jpg"
alt="Photo"
:srcset="cdn.srcset('/photo.jpg', [400, 800, 1200])"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</template>Auto CDN detection
autoLoader() looks at a URL's hostname and picks the right adapter for you —
no per-provider wiring when the asset is already served from a recognizable
CDN host. It fingerprints 8 of the 12 adapters this way: Cloudinary, imgix,
Bunny, ImageKit, Sanity, Storyblok, Contentful, Gumlet. Returns the URL
unchanged when nothing matches, so it's safe to run over every src
unconditionally — a local /images/photo.jpg just passes through.
import { autoLoader } from 'vue-image-kit/cdn'
autoLoader('https://res.cloudinary.com/demo/image/upload/photo.jpg', { width: 800 })
// → https://res.cloudinary.com/demo/w_800,q_auto,f_auto/image/upload/photo.jpg
autoLoader('/local/photo.jpg', { width: 800 })
// → '/local/photo.jpg' — no recognized CDN host, unchangedThe other 4 adapters (Netlify, Vercel, Cloudflare, TwicPics) run on your
own domain rather than a distinctive one, so there's no hostname to
fingerprint — pass them explicitly via config.hosts, keyed by your actual
domain:
import { autoLoader, netlify } from 'vue-image-kit/cdn'
autoLoader(src, { width: 800 }, {
hosts: { 'myapp.netlify.app': netlify({ origin: 'https://myapp.netlify.app' }) },
})autoSrcset() is the same detection, building a real per-width srcset (one
distinct URL per width, via the adapter's own .srcset()) instead of a
single URL — unlike autoLoader, it returns undefined rather than the
input unchanged when nothing is detected, since there's no sensible
"unchanged srcset" to fall back to:
import { autoSrcset } from 'vue-image-kit/cdn'
autoSrcset('https://mysite.imgix.net/photo.jpg', [400, 800, 1200])
// → 'https://mysite.imgix.net/photo.jpg?w=400&auto=format 400w, ...'Auto CDN detection with VImage
VImage's cdn prop wires autoLoader()/autoSrcset() straight into the
component — no manual detection, no manual src rewriting:
<VImage src="https://res.cloudinary.com/demo/image/upload/photo.jpg" alt="Photo" cdn />Combined with widths, each candidate gets a real CDN-transformed URL via
the adapter's .srcset() instead of widths' usual "same URL, different w
descriptor" behavior (generateSrcset — see "Network-aware loading" for why
that limitation exists for the non-CDN case):
<VImage
src="https://mysite.imgix.net/photo.jpg"
alt="Photo"
cdn
:widths="[400, 800, 1200]"
/>Pass an AutoLoaderConfig object instead of true to cover the "your own
domain" providers (Netlify/Vercel/Cloudflare/TwicPics) via hosts, same as
autoLoader() itself:
<script setup lang="ts">
import { netlify } from 'vue-image-kit/cdn'
const cdnConfig = { hosts: { 'myapp.netlify.app': netlify({ origin: 'https://myapp.netlify.app' }) } }
</script>
<template>
<VImage src="https://myapp.netlify.app/photo.jpg" alt="Photo" :cdn="cdnConfig" />
</template>Has no effect on an unrecognized host (passthrough, same as autoLoader), or
on an explicit SrcSet object / densities — those are already
format/resolution choices you made by hand.
buildSizes helper
Build a sizes attribute string from a breakpoint-keyed object — works with the plugin's named breakpoints.
import { buildSizes } from 'vue-image-kit'
const breakpoints = { sm: '(max-width: 640px)', md: '(max-width: 1024px)' }
buildSizes({ sm: '100vw', md: '50vw', default: '33vw' }, breakpoints)
// → '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'generatePreloadLink
Generate a <link rel="preload"> HTML string for critical above-the-fold images. Use in Nuxt's useHead or inject into SSR <head> to improve LCP.
import { generatePreloadLink, generateSrcset } from 'vue-image-kit'
const srcset = generateSrcset('/hero.jpg', [400, 800, 1200])
const link = generatePreloadLink('/hero.jpg', {
srcset,
sizes: '100vw',
})
// → '<link rel="preload" as="image" href="/hero.jpg" imagesrcset="..." imagesizes="100vw">'In Nuxt:
<script setup lang="ts">
import { generatePreloadLink } from 'vue-image-kit'
useHead({
link: [{ innerHTML: generatePreloadLink('/hero.jpg', { sizes: '100vw' }) }]
})
</script>useImagePreloader
Preload a batch of images before navigation — useful for galleries and carousels.
<script setup lang="ts">
import { useImagePreloader } from 'vue-image-kit'
const { preload, progress, isComplete, errors } = useImagePreloader()
async function goToNextSlide() {
await preload(['/slide-2.jpg', '/slide-3.jpg'])
// All images are cached — transition is instant
currentSlide.value++
}
</script>
<template>
<div v-if="!isComplete">Loading {{ progress }}%…</div>
</template>fetchpriority & decoding
Control browser prioritization and decoding strategy:
<!-- Hero image: load first, decode async -->
<VImage
src="/hero.jpg"
alt="Hero"
:lazy="false"
fetchpriority="high"
decoding="async"
/>
<!-- Below-the-fold: deprioritize -->
<VImage
src="/footer-banner.jpg"
alt="Banner"
fetchpriority="low"
/>| Prop | Type | Default | Description |
|---|---|---|---|
| fetchpriority | 'high' \| 'low' \| 'auto' | — | Browser fetch priority hint |
| decoding | 'async' \| 'sync' \| 'auto' | 'async' | Image decoding mode |
Error retry
Automatically retry failed image loads with exponential backoff:
<VImage
src="/flaky-image.jpg"
alt="Photo"
:max-retries="3"
:retry-delay="500"
/>| Prop | Type | Default | Description |
|---|---|---|---|
| maxRetries | number | 0 | Max retry attempts |
| retryDelay | number | 1000 | Initial delay in ms (doubles each retry) |
Network-aware loading
useNetworkAware() wraps the browser's Network Information API — saveData (the
user opted into data savings) and effectiveType ('slow-2g' | '2g' | '3g' | '4g').
SSR-safe (saveData starts false on the server) and reactive to the connection's
change event. Support is Chromium-only today (Firefox/Safari don't implement the
API) — saveData just stays false there, so nothing breaks, it simply can't help.
import { useNetworkAware } from 'vue-image-kit'
const { saveData, effectiveType } = useNetworkAware()Two places already use it:
useImagePreloader()silently skipspreload()calls whilesaveDatais on — preloading trades bandwidth for a smoother later transition, the wrong trade once the user asked to save data.VImage'srespectSaveDataprop (opt-in, defaultfalse): whilesaveDatais on, it neutralizespriority(the image stays lazy instead of being forced eager/high-priority) and downgradessrcto the smallest URL it can actually find one for — the lowest key in adensitiesmap, or the smallestwcandidate inimage.srcset(a manifest/?vikvalue). Plainwidthshas no distinct URL to downgrade to (seegenerateSrcset— the browser negotiates via thewdescriptor against one URL, not a URL per width) so it's a no-op there.
<VImage
src="/photo.jpg"
alt="Photo"
priority
respect-save-data
:densities="{ 1: '/photo.jpg', 2: '/[email protected]' }"
/>For a direct check outside a component (e.g. before kicking off a batch preload
yourself), isSaveDataEnabled() is the same check without the reactive wrapper:
import { isSaveDataEnabled } from 'vue-image-kit'
if (!isSaveDataEnabled()) {
await preload(nextSlideU