@macrulez/vue-image-kit
Image Kit

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 an optional fade-in transition — with zero external runtime dependencies and a small, tree-shakeable footprint.
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), audits how images are used across the project, and fills in missing placeholders for existing <VImage> usages, 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-based lazy loading after hydration.
Features
Placeholders
- HazeHash placeholder — the preferred one —
hazehashprop on<VImage>: a 16–48 byte string (28 by default) from thehazehashpackage that decodes to a blurred preview with the right aspect ratio and, when the image has transparency, its alpha channel; about 49% lower perceptual error than BlurHash and 20% lower than ThumbHash at the same size. When several hashes are given,hazehashwins overblurhash,thumbhashandplaceholder; the build tools (placeholderscommand, Vite plugin,generate) make it by default whenhazehashis installed. Needs the optional peerhazehash - Blurhash placeholder — custom in-house decoder (no external packages); decodes to a small data URL applied as a CSS background on the image itself, swapped out once the real image loads; SSR renders the
<img>directly, sized viawidth/heightto preserve aspect-ratio (opt-inssrPlaceholderadds a ready preview as its background, so there is a blur even before hydration) - 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; low-res preview applied as a background image behind the real image, swapped out once it loads; optionalfadeInprop cross-fades the swap 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 - Placeholders manifest — register a
src → { hazehash, blurhash, thumbhash, color, width, height }map once (app.use(VImageKitPlugin, { placeholders }), or the Nuxt module'splaceholdersoption) and every<VImage>picks up its placeholder and size bysrc, with no per-usage props; generated bynpx vue-image-kit placeholders - 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 fetchpriorityprop —highfor LCP images,lowfor below-the-fold- Error retry —
maxRetriesprop with exponential backoff; automatically retries failed loads without manual intervention
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 useBackgroundImage()— composable for lazy + responsive (image-set()) backgrounds with blur-up
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 }useBreakpoints(),useLazyLoad()— the lower-level composablesVImageitself is built on, exposed for fully custom markupuseBlurhash()— decodes a BlurHash to a<canvas>for fully custom markup that wants its own placeholder elementuseNetworkAware()— reactive save-data/connection-type state
CDN adapters — @macrulez/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 wiringdetectCdnProvider()— tells which of those providers a URL belongs to ('cloudinary','imgix', …), ornull
CLI — npx vue-image-kit
generate— resize images to multiple widths, convert to WebP/AVIF, generate LQIP base64, encode HazeHash/BlurHash/ThumbHash, write a TypeScript manifest (images.ts) with all metadata pre-computedscan— finds every<VImage>(template, JSX,h()),v-lazy-imganduseImage()/useBackgroundImage()across the project, classifies each image source (local import,public/, CDN with provider, remote URL,?vik, dynamic) and prints a summary table with warnings (missingalt, no size, no placeholder, missing files…);--format json|md|csv,--fail-onfor CIplaceholders— computes HazeHash, BlurHash, ThumbHash or the dominant color for every<VImage>without a placeholder whose image can be resolved (local files,public/; CDN/remote URLs with--remote), and delivers it through the placeholders manifest — or straight into the template where a runtime lookup can't worksharpas optional peer dependency — not included in the browser bundle
Ecosystem
- Nuxt module —
@macrulez/vue-image-kit/nuxt; auto-registers<VImage>andv-lazy-img; auto-imports every composable and utility - Vite plugin —
@macrulez/vue-image-kit/vite; runs the CLI processor onbuildStart; build-time imports via?vik(variants + placeholders),?placeholder(a list of fields:?placeholder=color,blurhash),?blurhash/?thumbhash/?color/?size/?aspect/?preview(placeholders only, no files written);generate: falseskips the batch run;placeholders: { dirs, urls }serves a placeholders manifest for whole folders asvirtual:vue-image-kit/placeholders; optional on-demand dev serving - Self-hosted on-demand server —
@macrulez/vue-image-kit/server; a small framework-agnostic Node request handler for when there's no CDN and a build step isn't wanted - Zero external runtime dependencies — only Vue 3 as peer dep; full ESM + CJS, tree-shakeable,
sideEffects: false
When you'd reach for this
An image is almost always the heaviest thing on a page — and almost always what determines LCP, the metric Google and real users judge loading speed by. vue-image-kit bundles the whole set of techniques usually applied one at a time by hand: the right format, lazy loading, placeholders, priority for the one image that matters most.
- The hero image on screen shouldn't wait its turn — A banner or product photo visible the moment the page opens gets priority and starts loading ahead of every other image — otherwise it's that image, not some small icon near the bottom, that decides how fast the page feels.
- Images below the fold shouldn't load for nothing — A feed of fifty photos doesn't pull all fifty files the moment the page opens — images far from the viewport only start loading once the user has scrolled nearly to them.
- Images live in a CDN, not the project folder — One project keeps images in Cloudinary, another in its own cloud, and each service builds the URL for a given size and format its own way — that URL gets assembled automatically for whichever provider is in use, instead of by hand for each one.
- While a photo is still loading, its spot shouldn't be empty — An empty box that suddenly turns into a picture makes the page jump and feels unpolished — the spot where an image hasn't loaded yet shows a blurred or color placeholder prepared ahead of time instead.
Installation
| Peer dependency | Version | Required |
|---|---|---|
vue |
^3.0.0 |
yes |
sharp |
>=0.33.0 |
only for the CLI (generate, placeholders) / Vite plugin / self-hosted server |
hazehash |
>=0.1.1 |
for hazehash placeholders: decoding them in the browser, and encoding them in generate, placeholders and the Vite plugin (the default there when it is installed) |
thumbhash |
>=0.1.0 |
only for generate --thumbhash, placeholders --mode thumbhash and the Vite plugin's ?thumbhash/?placeholder=thumbhash/?vik build-time imports |
npm install @macrulez/vue-image-kit
sharp, hazehash and thumbhash are optional peer dependencies. Install hazehash to use the preferred placeholder (it is needed in the browser to decode it, so a regular dependency), and sharp (plus thumbhash, only for ThumbHash) if you use the CLI, the Vite plugin, or the self-hosted server:
npm install hazehash
npm install sharp --save-dev
Quick start — Vue 3
// main.ts
import { createApp } from 'vue'
import { VImageKitPlugin } from '@macrulez/vue-image-kit'
import '@macrulez/vue-image-kit/style.css'
import App from './App.vue'
const app = createApp(App)
app.use(VImageKitPlugin)
app.mount('#app')
The CSS import ships the small set of layout/placeholder classes <VImage> relies on (box sizing per layout mode, the shimmer animation). It's a handful of plain, overridable classes, not a design system — nothing to configure.
<template>
<VImage
src="/photo.jpg"
alt="Mountain landscape"
:width="1200"
:height="600"
hazehash="Ed7UwRWKKv5znndNd2Ba284jhm2TLgpUMa0UkQ"
/>
</template>
<VImage> is registered globally by the plugin. No import needed.
Quick start — Nuxt 3
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@macrulez/vue-image-kit/nuxt'],
vueImageKit: {
breakpoints: {
sm: '(max-width: 640px)',
md: '(max-width: 1024px)',
},
},
})
<VImage>, v-lazy-img, all composables, and the CSS import from the Vue 3 example above are registered automatically — no imports needed.
More examples
A different shot per screen, not just a different size
sources swaps in a genuinely different image — different crop, different composition — per breakpoint. Real art direction through <picture>, not a stretched resize of the same photo.
<template>
<VImage
src="/hero-desktop.jpg"
alt="Hero"
:sources="{
sm: '/hero-mobile.jpg',
md: '/hero-tablet.jpg',
}"
/>
</template>
<!-- Generates a real <picture> with one <source media="..."> per
breakpoint, sorted automatically, plus the fallback <img>. -->
Each breakpoint can also carry its own width/height (or a full build-time ImageMeta object, e.g. from a ?vik import) when its crop has a different aspect ratio than the root image:
<template>
<VImage
src="/desktop-portrait.jpg"
:width="720"
:height="1237"
:sources="{
tablet: { src: '/tablet-landscape.jpg', width: 1400, height: 700 },
}"
:breakpoints="{ tablet: '(max-width: 1024px)' }"
alt="Hero"
/>
</template>
Give width/height here and VImage reserves the box for the active breakpoint's own aspect ratio — not just the root image's — so the idle placeholder and blurhash decode already have the right proportions before the photo loads, and switching breakpoints never causes a layout shift. Without them, <source> renders with no dimensions and the placeholder falls back to the root width/height, exactly like before this existed. width/height are also both-or-nothing per entry: setting only one is ignored (with a dev warning) rather than distorting the box.
Respects Save-Data — doesn't pull the heavy version
respect-save-data downgrades src to the lightest candidate it can find and drops priority loading while the visitor has data-saving mode on — their phone decides, not the developer.
<template>
<VImage
src="/photo.jpg"
alt="Photo"
priority
respect-save-data
:densities="{ 1: '/photo.jpg', 2: '/photo@2x.jpg' }"
/>
</template>
<!-- While the visitor has Save-Data on: priority is neutralized (stays
lazy instead of eager), and src downgrades to the lightest candidate
it can find — the lowest density here, @1x instead of @2x. -->
Find every image, then fill in missing placeholders
npx vue-image-kit scan # summary table + warnings
npx vue-image-kit scan --details --format md --out image-report.md
npx vue-image-kit placeholders --dry-run # preview
npx vue-image-kit placeholders # local and public/ images
npx vue-image-kit placeholders --remote --hosts res.cloudinary.com --limit 200
placeholders writes src/image-placeholders.ts. Register it once and every <VImage> whose src is in it gets a placeholder and a size, without touching the templates:
import { VImageKitPlugin } from '@macrulez/vue-image-kit'
import placeholders from './image-placeholders'
app.use(VImageKitPlugin, { placeholders })
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@macrulez/vue-image-kit/nuxt'],
vueImageKit: { placeholders: './image-placeholders.ts' },
})
Images imported from the source tree (import hero from './hero.jpg') end up under a hashed build URL, so a lookup by src can't find them — for those, and for the whole project if no registration is found, the values are written into the template as blurhash/placeholder-color and :width/:height props instead. Files with uncommitted changes are never edited (--force-write overrides), --no-write keeps sources untouched entirely. Usages that already have a placeholder are skipped; --replace redoes their static blurhash/thumbhash/placeholder/placeholder-color attributes with the chosen --mode (bound values and :image are left alone). Entries of a literal :sources prop (art direction) get their own placeholder too — and <VImage> shows the matching entry's placeholder at that breakpoint instead of the root image's.
Images whose src is built at runtime (:src="category.image") can't be found in a template, so give the manifest whole folders instead:
npx vue-image-kit placeholders --dir public/images --dir src/img=/assets/img --url https://cdn.example.com/hero.jpg
Or skip the command and let the Vite plugin build the same manifest on every build — in Nuxt it's one option, with no vite.plugins entry and no registration:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@macrulez/vue-image-kit/nuxt'],
vueImageKit: {
placeholders: { dirs: ['public/images'], tuning: { components: [4, 3] } },
},
})
// plain Vite
import { vueImageKit } from '@macrulez/vue-image-kit/vite'
import placeholders from 'virtual:vue-image-kit/placeholders'
vueImageKit({ generate: false, placeholders: { dirs: ['public/images'] } })
app.use(VImageKitPlugin, { placeholders })
For a handful of imported files, import ph from './photo.jpg?placeholder' returns the <VImage> props directly (<VImage v-bind="ph" />); generate: false keeps the plugin from resizing anything. ?placeholder=color,blurhash picks any fields (blurhash, thumbhash, color, size, preview, aspect, all; &shape=raw keeps the names as listed), ?color, ?size, ?aspect, ?preview, ?blurhash and ?thumbhash return a single value, and &components=6x4&sample=64&strategy=average tune one import. vueImageKit({ types: true }) writes typings for list imports. placeholders: { imports: true } computes a placeholder for every statically imported image on its own, with no query and no template edits; imports: { preview: true | ['/hero/'] } also renders a ready data-URL preview, and <VImage ssr-placeholder> puts it into the server-rendered HTML so the blur shows before any JavaScript runs. v-lazy-img and useBackgroundImage() read the manifest too.
Documentation & links
- 📖 Full documentation: npm.vuecraft.ru/en/packages/vue-image-kit
- 🌐 VueCraft: vuecraft.ru/en
- 👤 Author: macrulez.ru/en
- 💻 GitHub: macrulezru/vue-image-kit
- 📦 NPM: @macrulez/vue-image-kit
- 🐛 Issues: github.com/macrulezru/vue-image-kit/issues
License
MIT
💖 Support the project
Open source takes time and effort. If this library saves you time or brings value, consider supporting further development.
Thank you for being part of this journey. ❤️
Related Packages
Automatically generates placeholders, srcset, sizes, and lazy loading to reduce...
Nuxt module for on-demand image optimization with caching, responsive srcset/siz...
Nuxt module: render TIFFs via @nuxt/image off the main thread in a Service Worke...
Universal image rendering engine for Vue — supports JPEG, PNG, WebP, GIF, HEIC,...
Version History
| Version | Released | License |
|---|---|---|
| 1.7.1 | MIT | |
| 1.7.0 | MIT | |
| 1.6.2 | MIT | |
| 1.6.1 | MIT | |
| 1.6.0 | MIT | |
| 1.5.0 | MIT | |
| 1.4.0 | MIT | |
| 1.3.2 | MIT | |
| 1.3.1 | MIT | |
| 1.3.0 | MIT | |
| 1.2.0 | MIT | |
| 1.1.8 | MIT | |
| 1.1.7 | MIT | |
| 1.1.6 | MIT | |
| 1.1.5 | MIT | |
| 1.1.4 | MIT | |
| 1.1.3 | MIT | |
| 1.1.2 | MIT | |
| 1.1.1 | MIT | |
| 1.1.0 | MIT | |
| 1.0.6 | MIT | |
| 1.0.5 | MIT | |
| 1.0.3 | MIT | |
| 1.0.2 | MIT | |
| 1.0.1 | MIT |