Vite Plugin
Process images at build time — same as the CLI but integrated into the Vite lifecycle. Runs on buildStart and re-runs in dev mode when source images change.
Integration Recipes show ready-made configurations for every case — with a note on what each is for and when to use it.
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { vueImageKit } from '@macrulez/vue-image-kit/vite'
export default defineConfig({
plugins: [
vue(),
vueImageKit({
input: './src/images',
output: './public/images',
widths: [400, 800, 1200],
manifest: './src/assets/images.ts',
}),
],
})All CLI options are supported. sharp must be installed as a dev dependency.
buildStart/handleHotUpdate call the same generate() the CLI does, so incremental generation applies here too — and is auto-enabled during vite dev specifically (not vite build) unless you set incremental explicitly. A single source-file change in dev reprocesses just that file, not the whole batch.
A hot update regenerates only for an image inside input and never for a file inside output, so the files the plugin writes itself cannot trigger another run. Runs never overlap: changes that arrive while one is running are collapsed into a single follow-up run. output may even sit inside input; it is skipped when the folder is scanned.
SVG images
A vector image has no hazehash, blurhash or thumbhash — only a dominant color and a size:
import icon from './logo.svg?placeholder'
// → { placeholderColor: '#ee3333', width: 64, height: 64 }The size is read from the file's own width/height (or viewBox) instead of rasterizing it, and a large SVG is rendered small for the color, so a 30000×30000 vector doesn't hit sharp's pixel limit. ?hazehash, ?blurhash and ?thumbhash on an SVG return an empty string and print a warning that points to ?placeholder. A corrupt SVG doesn't fail the build: the plugin warns and returns the declared size (or an empty object). A corrupt raster image still fails, loudly.
Build-time imports
The plugin also resolves query-suffixed imports, so you never wire props by hand — the metadata comes straight into your JS at build time:
import meta from './photo.jpg?vik'
// → { src, srcset, webp, avif, width, height, placeholder, blurhash, thumbhash, name, src400, ... }
import hash from './photo.jpg?thumbhash'
// → 'base64string'Pass the metadata straight to <VImage>'s image prop — no manual field wiring:
<script setup lang="ts">
import meta from './hero.jpg?vik'
</script>
<template>
<VImage :image="meta" alt="Hero" />
</template>?vikresizes/encodes the image intooutputand returns the full manifest entry (URLs usepublicPath, exactly like the generated manifest). The ThumbHash is always included.?placeholder(with a field list),?hazehash,?blurhash,?thumbhash,?color,?size,?aspectand?previewcompute only the placeholder data and write no files — see Placeholders only and Choosing what to compute.
All of them re-run when the source image changes in dev. sharp is required; thumbhash is required for ThumbHash output.
TypeScript — enable typed ?vik / ?placeholder / ?hazehash / ?blurhash / ?thumbhash imports by referencing the bundled declarations once (e.g. in env.d.ts):
/// <reference types="@macrulez/vue-image-kit/vite/client" />Placeholders only, no resizing
When the project doesn't need responsive variants — only a blur while the image loads — turn the batch run off with generate: false. The plugin then touches nothing on its own; it only answers placeholder imports and the placeholders manifest, so input doesn't have to exist:
// vite.config.ts
vueImageKit({ generate: false })<script setup lang="ts">
import placeholder from './photo.jpg?placeholder'
</script>
<template>
<VImage src="/photo.jpg" v-bind="placeholder" alt="Photo" />
</template>?placeholder returns the props of <VImage> ready to spread — { hazehash, placeholderColor, width, height } (blurhash instead of hazehash when the package is not installed) — computed at build time from the real file. Which hash it carries is set by the value:
?placeholder— a HazeHash (a BlurHash when thehazehashpackage is not installed ormodeisblurhash), the dominant color and the original size.?placeholder=hazehash— a HazeHash explicitly.?placeholder=blurhash— a BlurHash explicitly.?placeholder=thumbhash— a ThumbHash instead of the BlurHash.?placeholder=color— only the dominant color and the size.?hazehash/?blurhash/?thumbhash— just the hash string,export default 'LJ9+E%}}…'.
The values are identical to what the placeholders CLI command computes for the same file. With generate: false the ?vik import still works and still writes its resized variants into output when you ask for it.
Choosing what to compute
?placeholder takes a list of fields, so one import returns exactly what you need:
import ph from './photo.jpg?placeholder=color,blurhash'
// → { blurhash: 'L6T9R{,YfQ,Y|cjtfQjtfQfQfQfQ', placeholderColor: '#fe0000' }
import all from './photo.jpg?placeholder=all&shape=raw'
// → { hazehash, blurhash, thumbhash, color, preview, size: { width, height }, aspect }Fields are separated by commas (spaces work too), in any order, and a field named twice counts once:
hazehash— the HazeHash string (needs thehazehashpackage).blurhash— the BlurHash string.thumbhash— the ThumbHash as base64.color— the dominant color as#rrggbb(an image with no opaque pixel has none, and the field is left out).size— the realwidthandheight.preview— a tiny PNG as adata:image/png;base64,…URI, decoded from the ThumbHash. It is ready to use in asrcor a CSSbackgroundwithout decoding anything on the page.aspect—width / heightas a number, for example2for a 200×100 image.all— every field above.
Only the listed fields come back. Ask for size as well when <VImage> needs width and height. The older single values keep their shape: ?placeholder, ?placeholder=hazehash, ?placeholder=blurhash, ?placeholder=thumbhash and ?placeholder=color still return the hash plus color and size.
Result shape
&shape=props (the default) uses <VImage> prop names, so <VImage v-bind="ph" /> works: color becomes placeholderColor, preview becomes placeholder, size becomes width and height. aspect is not a <VImage> prop, so it is skipped with a warning. &shape=raw keeps the names as listed and returns size as an object, with aspect included:
import raw from './photo.jpg?placeholder=color,size,aspect&shape=raw'
// → { color: '#fe0000', size: { width: 200, height: 100 }, aspect: 2 }One value at a time
Each field also has its own import that returns the bare value:
import color from './photo.jpg?color' // '#fe0000'
import size from './photo.jpg?size' // { width: 200, height: 100 }
import aspect from './photo.jpg?aspect' // 2
import preview from './photo.jpg?preview' // 'data:image/png;base64,…'
import blur from './photo.jpg?blurhash' // 'L6T9R…'
import thumb from './photo.jpg?thumbhash' // '1fsDBI…'A value that could not be computed comes back as '' (or 0 for ?aspect and 0×0 for ?size). A bare ?size, ?color, ?preview or ?aspect is required — ?size=large is left alone, so another plugin's parameter with the same name is not taken over.
Settings for one import
Any of these imports accepts the settings of the hashes tuning for that one file only:
components— BlurHash components,4x3by default, each side 1–9.sample— the size the image is downscaled to before hashing, 8–256.strategy—dominant(default) oraveragefor the color.
import hash from './photo.jpg?blurhash&components=6x4'
import ph from './photo.jpg?placeholder=blurhash,color&sample=64&strategy=average'An invalid value prints a warning and the default is used. The settings of a single import never touch the shared cache or the placeholders manifest.
Typing a list
/// <reference types="@macrulez/vue-image-kit/vite/client" /> types the fixed imports (?color, ?size, ?placeholder=color, …). A list or an import with settings cannot be typed that way: a TypeScript module pattern allows only one *, and it is already needed for the file path. Let the plugin write the declarations instead:
vueImageKit({ types: true })The plugin records every such import it sees and writes src/vik-imports.d.ts (types: 'path/to/file.d.ts' picks another place). A list is typed loosely, with every field optional (hazehash?: string, blurhash?: string, placeholderColor?: string, …), so the order and the set of fields don't matter. Entries are only ever added, never removed, so a dev server that has only loaded some pages doesn't shrink the file; delete the file to regenerate it. Until an import has been seen once, the editor reports it as an unknown module.
Examples
Blur and size for one image. size gives <VImage> its width and height, so the page doesn't jump when the image loads:
<script setup lang="ts">
import photo from './assets/photo.jpg'
import ph from './assets/photo.jpg?placeholder=blurhash,size'
</script>
<template>
<VImage :src="photo" v-bind="ph" alt="Photo" />
</template>A ready-made preview as a CSS background. ?preview is a data URI of a tiny PNG, so nothing has to be decoded on the page:
<script setup lang="ts">
import hero from './assets/hero.jpg'
import preview from './assets/hero.jpg?preview'
</script>
<template>
<section class="hero" :style="{ backgroundImage: `url(${preview})` }">
<img :src="hero" alt="" loading="lazy" />
</section>
</template>Reserve the space with the aspect ratio.
<script setup lang="ts">
import cover from './assets/card.jpg'
import aspect from './assets/card.jpg?aspect'
</script>
<template>
<div :style="{ aspectRatio: aspect }">
<img :src="cover" alt="" />
</div>
</template>Several values at once, under their own names.
import raw from './assets/photo.jpg?placeholder=color,size,aspect&shape=raw'
const { color, size, aspect } = raw
console.log(color, size.width, size.height, aspect)A whole folder with import.meta.glob. The query is the same string:
const placeholders = import.meta.glob('./assets/gallery/*.jpg', {
query: '?placeholder=blurhash,size',
import: 'default',
eager: true,
})Settings for one image only.
import hash from './assets/banner.jpg?blurhash&components=8x3'
import avg from './assets/banner.jpg?color&strategy=average'Typings for the lists. Reference the bundled declarations once, and let the plugin write the rest:
// vite.config.ts
vueImageKit({ generate: false, types: true })// env.d.ts
/// <reference types="@macrulez/vue-image-kit/vite/client" />SVG
A vector image only has color, size and aspect. Asking for other fields skips them with a warning. See SVG images.
Placeholder options
Every placeholder setting lives under one key, placeholders: in the options of the Vite plugin, or of the Nuxt module. This section shows all of them together and says what each one does; the sections after it explain the parts in detail.
In a Vite project
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { vueImageKit } from '@macrulez/vue-image-kit/vite'
export default defineConfig({
plugins: [
vue(),
vueImageKit({
generate: false,
types: true,
placeholders: {
mode: 'blurhash',
tuning: { components: [4, 3], sample: 100, color: 'dominant' },
imports: {
extensions: ['.png', '.jpg', '.jpeg', '.webp', '.avif'],
exclude: ['src/assets/sprites/'],
preview: ['src/assets/hero/', '**/cover-*.webp'],
},
dirs: ['public/images', { dir: 'src/img', urlPrefix: '/assets/img' }],
urls: ['https://cdn.example.com/hero.jpg'],
root: '.',
publicDir: 'public',
concurrency: 4,
timeout: 15000,
maxBytes: 15 * 1024 * 1024,
refreshRemote: false,
},
}),
],
})In a Nuxt project
The module registers the Vite plugin itself, so the same placeholders object goes under vueImageKit in nuxt.config.ts. generate: false is set for you, and relative paths are resolved from the Nuxt root:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@macrulez/vue-image-kit/nuxt'],
vueImageKit: {
placeholders: {
mode: 'blurhash',
tuning: { components: [4, 3], sample: 100, color: 'dominant' },
imports: {
extensions: ['.png', '.jpg', '.jpeg', '.webp', '.avif'],
exclude: ['src/assets/sprites/'],
preview: ['src/assets/hero/', '**/cover-*.webp'],
},
dirs: ['public/images', { dir: 'src/img', urlPrefix: '/assets/img' }],
urls: ['https://cdn.example.com/hero.jpg'],
manifest: './image-placeholders.ts',
},
},
})You rarely need all of it. The smallest useful setting is placeholders: { imports: true }; add preview only for the images that need a blur in the server-rendered HTML.
Options of the Vite plugin that matter here
generate—boolean· default:true. Run the batch processing ofinputintooutput.falseleaves only the placeholder features: the plugin touches no file andinputdoesn't have to exist. The Nuxt module always sets it tofalsefor the placeholder plugin.types—boolean | string· default: off. Write a.d.tsfor the imports with a field list or settings;truewritessrc/vik-imports.d.ts, a string picks another path. See Typing a list.placeholders—object. Everything below.
placeholders
imports—boolean | object· default: off. Compute a placeholder for every image a script or single-file component imports, with no query and no template edits.truetakes the defaults of the three settings below; an object tunes them. See A placeholder for every imported image.imports.extensions—string[]· default:['.png', '.jpg', '.jpeg', '.webp', '.avif', '.gif', '.tif', '.tiff']. The file types to take. SVG is never taken.imports.exclude—string[]· default:[]. Images to skip. A list of path patterns.imports.preview—boolean | string[]· default:false. Also render a readydata:preview and register it, so<VImage ssr-placeholder>can put it into the server-rendered HTML.trueis every imported image; a list of path patterns is only the images it picks. Needs thethumbhashpackage. See A ready preview for server rendering.
mode—'hazehash' | 'blurhash' | 'thumbhash' | 'color'· default:'hazehash'when thehazehashpackage is installed, otherwise'blurhash'. What a placeholder carries besides its dominant color and size: a HazeHash, a BlurHash, a ThumbHash, or nothing more. It is the mode of the automaticimports, of a bare?placeholderimport and of every manifest entry.tuning—{ components?, sample?, color?, budget? }. How hashes and colors are computed: BlurHash components (default[4, 3]), the size an image is downscaled to before hashing (default100), and'dominant'or'average'for the color (default'dominant'). See Tuning the hashes.dirs—(string | { dir, urlPrefix? })[]. Folders whose images get an entry in the placeholders manifest (virtual:vue-image-kit/placeholders), for images whosesrcis built at runtime.urlPrefixis the URL the folder is served under when it lies outside the public directory.urls—string[]. Remote or CDN images that are downloaded at build time and get a manifest entry.manifest—string, Nuxt only. A manifest file (the onenpx vue-image-kit placeholderswrites) to merge with the folders; its entries win.root—string· default: the project root. The directory that relativedirsare resolved from. Nuxt sets it to the Nuxt root. (excludeandpreviewpatterns are always measured from the project root of Vite.)publicDir—string· default: the Vite public directory (public). Files inside it are keyed in the manifest by their URL path.concurrency—number· default:4. Images processed in parallel.timeout—number· default:15000. Milliseconds to wait for a remote image.maxBytes—number· default:15728640(15 MB). A remote image larger than this is skipped.refreshRemote—boolean· default:false. Download remote images again even if a copy is cached.
Results are cached in node_modules/.cache/vue-image-kit/placeholders.json; a local file is recomputed only when its modification time or size changes, and changing tuning drops the cache.
A placeholder for every imported image
When a project imports its images (import hero from './hero.webp', or a plain src="@/assets/hero.webp" in a template), turn on imports and every one of them gets its placeholder at build time, with no query and no edit to any template:
// vite.config.ts
vueImageKit({
generate: false,
placeholders: { imports: true },
})// nuxt.config.ts
vueImageKit: {
placeholders: { imports: true },
}<script setup lang="ts">
import hero from '@/assets/hero.webp'
</script>
<template>
<VImage :src="hero" alt="Hero" />
</template><VImage> finds the blur and the real size on its own. The plugin computes them from the file, then registers them under the value the import produced — the dev URL, or the hashed URL in a production build — so the lookup by src works in dev, in the build and during server rendering.
What is covered: default imports of .png, .jpg, .jpeg, .webp, .avif, .gif and .tif files in scripts and single-file components, the imports Vue generates from src="…" in a template, and import.meta.glob(…, { eager: true }) (Vite expands it into imports). Not covered: SVG, imports with a query (?url), lazy globs (import()), and URLs built at run time. For those, import with ?placeholder or use a manifest from folders.
Settings go next to imports:
placeholders: {
mode: 'thumbhash',
imports: {
extensions: ['.png', '.webp'],
exclude: ['/sprites/', 'canvas-'],
},
}mode—hazehash(default when thehazehashpackage is installed),blurhash(default otherwise) orthumbhash, the same option as for the other placeholders;thumbhashneeds thethumbhashpackage.extensions— the file types to take.exclude— images to skip, for ones that are never shown through<VImage>(a canvas sprite, say). Takes the same path patterns aspreview.preview—true(every imported image) or a list of path patterns: also render a readydata:preview. See A ready preview for server rendering. Off by default.
A corrupt image prints a warning and simply gets no placeholder; it doesn't fail the build. sharp must be installed as a dev dependency, and the first build computes every image once and keeps the result in the same cache as the other placeholders.
Choosing images by path
exclude and preview take a list of patterns. Each pattern is compared with the real path of the file, relative to the project root (src/assets/hero/a.webp), so it doesn't matter how the file is imported: @/assets/hero/a.webp, ~/assets/… and ../assets/… all point at the same path. In the dev server the path is the one the browser requests, with the base cut off.
- A directory — a pattern ending in
/:'src/assets/hero/'takes everything inside that directory, at any depth. - An exact file — the full relative path:
'src/assets/hero/cover.webp'. - A part of a path — any other text is looked for inside the path:
'hero','-large','/sprites/'. - A glob — a pattern with
*is matched against the whole path:*stands for any characters inside one directory name,**for any number of directories.'src/assets/*.png'takes the PNG files directly insrc/assets,'src/**/hero-*.webp'takes the matching files anywhere undersrc.
A leading ./ or / in a pattern is ignored. When a path can't be worked out (an import the bundler can't resolve), the pattern is compared with the import text as written.
A ready preview for server rendering
The blur that imports registers is a hash: it is decoded in the browser, so it shows up only after JavaScript has run. For the images that must have a blur in the server-rendered HTML, ask for a ready preview too:
placeholders: {
imports: {
preview: ['src/assets/hero/', '**/cover-*.webp'],
},
}preview: true— a preview for every imported image.preview: ['src/assets/hero/', '**/cover-*.webp']— only for the images the patterns pick, by directory, by exact file or by glob (see Choosing images by path). Use this to keep the bundle small.- absent /
false— no previews (default).
The plugin renders each preview (a small PNG as a data: URL) and registers it with the other fields, but nothing reaches the HTML by itself. The image says so explicitly with ssrPlaceholder:
<VImage :src="hero" alt="Hero" ssr-placeholder />The server then renders the <img> with the preview as its background; any <VImage> without the prop is unchanged. A preview adds a few kilobytes per image to the bundle (and to the HTML of the pages that use it), which is why it is a separate switch and why a list is better than true for a large project.
The preview is rendered from a ThumbHash, so the thumbhash package must be installed as a dev dependency, even when mode is blurhash. For one image without imports, import it directly — import preview from './hero.webp?preview' — and pass it as :placeholder="preview". See Preview in the server-rendered HTML.
Server-rendered preview, step by step
Install the dependencies. The preview is rendered from a ThumbHash with
sharp, so both packages are needed at build time, whatevermodeis:bashnpm install -D sharp thumbhashTurn on the preview in the config, for the images that need it. In Vite:
ts// vite.config.ts vueImageKit({ generate: false, placeholders: { imports: { preview: ['src/assets/hero/'] }, }, })In Nuxt:
ts// nuxt.config.ts export default defineNuxtConfig({ modules: ['@macrulez/vue-image-kit/nuxt'], vueImageKit: { placeholders: { imports: { preview: ['src/assets/hero/'] }, }, }, })imports.previewis what makes the plugin render the preview and keep it in the registry. Without it there is nothing forssr-placeholderto show.Mark the images that must show it, with
ssr-placeholderon<VImage>. The image is imported as usual:vue<script setup lang="ts"> import hero from '@/assets/hero/cover.webp' </script> <template> <VImage :src="hero" alt="Cover" :width="1200" :height="600" ssr-placeholder /> </template>Restart the dev server — module and plugin options are read on start.
Check the HTML the server sends. For a lazy image there is a transparent pixel instead of the real
src, the preview as a background, and the real image in<noscript>:html<img src="data:image/gif;base64,R0lGOD…" alt="Cover" width="1200" height="600" loading="lazy" style="background-image:url(data:image/png;base64,iVBOR…);background-size:cover;…" /> <noscript><img src="/_nuxt/cover.3f9a.webp" alt="Cover" width="1200" height="600" /></noscript>If
srcis the real file and there is nobackground-image, the image has no preview: check that the file matches a pattern inimports.preview, thatthumbhashis installed, and that the server was restarted.
Which images get a preview (the config) and which <VImage> use it (the prop) are separate on purpose: the plugin can't know at build time which component receives which image, and a preview costs a few kilobytes, so it is only made where you ask for it.
Placeholders manifest from folders
Set placeholders.dirs and the plugin computes a placeholders manifest for every image in those folders — including images whose src is built at runtime. The manifest is the default export of the virtual module virtual:vue-image-kit/placeholders:
vueImageKit({
generate: false,
placeholders: {
dirs: ['public/images', { dir: 'src/img', urlPrefix: '/assets/img' }],
urls: ['https://cdn.example.com/hero.jpg'],
mode: 'blurhash',
},
})import placeholders from 'virtual:vue-image-kit/placeholders'
app.use(VImageKitPlugin, { placeholders })dirs—(string | { dir, urlPrefix })[]. Folders scanned recursively, relative to the project root. A file under the public directory is keyed by its path from there; a folder outside it needs aurlPrefix— without one its files are keyed by their project path (/src/img/a.jpg), which only matches the dev-server URL, and the build warns about it.urls—string[]. Remote or CDN images, downloaded at build time; CDN URLs are fetched as a small rendition.mode—'hazehash' | 'blurhash' | 'thumbhash' | 'color'· default:hazehashwhen the package is installed, otherwiseblurhash. What each entry carries besides its dominant color and size. It is also the default for a bare?placeholderimport.tuning— see Tuning the hashes.publicDir,concurrency,timeout,maxBytes,refreshRemote— the public directory (default:public) and the same download limits as theplaceholderscommand.
The module is rebuilt when a file in dirs is added, removed or changed during vite dev, and its results are cached in node_modules/.cache/vue-image-kit/placeholders.json — the same cache the placeholders command uses — so a repeated build only reads the images that changed. ?placeholder, ?blurhash and ?thumbhash use that cache too.
The type of the module comes with the other declarations of @macrulez/vue-image-kit/vite/client.
Tuning the hashes
Every place that computes a placeholder — the plugin's ?placeholder/?blurhash/?thumbhash imports and its manifest, the placeholders command and the Nuxt option — takes the same tuning object:
vueImageKit({
generate: false,
placeholders: {
dirs: ['public/images'],
tuning: { components: [6, 4], sample: 128, color: 'average' },
},
})components—[x, y]· default:[4, 3]. BlurHash components per axis, 1–9 each. More components keep more detail and make the string longer.budget—number· default:28. Size of a HazeHash string in bytes, 7–48. Also?hazehash&budget=16for one import.sample—number· default:100. The longest side, in pixels, that the image is downscaled to before hashing; 8–256. A ThumbHash is always computed from at most 100px.color—'dominant' | 'average'· default:'dominant'. How the placeholder color is chosen: the most frequent color, or the mean of all opaque pixels.
Changing the tuning drops the cache, so no stale hash is reused.
On-demand dev serving
Both the CLI and the build-time imports above are batch/ahead-of-time — they process images before they're requested. If you'd rather not run a build step at all during development, set dev.onDemand: true and images resize on request instead, cached to disk after the first hit:
vueImageKit({
dev: { onDemand: true }, // mounts a handler at /_vik/image during `vite dev`
})<img src="/_vik/image?src=/photos/cat.jpg&w=800&format=webp" />This is dev-only — configureServer (the Vite hook it uses) never runs during vite build. For production without a CDN, mount the same handler in your own server — see Self-hosted on-demand server.
dev options
The handler behind dev.onDemand is the same createImageHandler the self-hosted server uses — root is fixed to Vite's own project root (server.config.root), everything else is the same tuning surface:
onDemand—boolean· default:false. Mounts the handler duringvite dev.route—string· default:/_vik/image. Route to mount it at.cacheDir—string· default:<root>/.vik-cache. See Self-hosted on-demand server → Options.maxAge—number· default:31536000(1 year).allowedWidths—number[], optional. Restrictwto exactly these values.maxWidth—number· default:4000. Upper bound forwwhenallowedWidthsisn't set.