Placeholders
HazeHash placeholder
HazeHash is the preferred placeholder: a 7–48 byte string (28 by default) from the hazehash package that decodes to a blurred preview with the right aspect ratio and, when the image has transparency, its alpha channel. At the same size its perceptual error is lower than BlurHash and ThumbHash.
<VImage
src="/photo.jpg"
alt="Photo"
:width="1200"
:height="800"
hazehash="Ed7UwRWKKv5znndNd2Ba284jhm2TLgpUMa0UkQ"
/>When several placeholders are given, hazehash wins: hazehash → blurhash → thumbhash → placeholder. It is the same for a manifest entry, image.hazehash, v-lazy-img and useBackgroundImage().
hazehash is an optional peer dependency that is loaded on demand, only when a hash is actually shown:
npm install hazehashWithout the package, <VImage> logs one warning and falls back to the next placeholder it has (blurhash, thumbhash, placeholder, placeholderColor).
Build tools make it for you. When hazehash is installed it is the default mode of the placeholders command, of the Vite plugin and of generate; mode: 'blurhash' keeps the previous behavior. The size of the string is set by tuning.budget (bytes, 7–48, default 28) — see Tuning the hashes.
Size of the preview
The preview is sized exactly like the image it stands for. Before loading, <VImage> renders an <img> with the same width and height attributes and the same classes as the loaded one (a transparent image of the same size as its src, the hash as its background), so the browser sizes both by the same rules and the same CSS: width: 50%, a fixed height, max-width, a parent that shrinks to its content. While the file downloads, the real <img> keeps that size through contain-intrinsic-size until its first bytes arrive. Nothing is computed in JavaScript and nothing jumps when the photo arrives. Only an aspect-ratio from width/height is set inline; give <VImage> those two props (or an image, or a manifest entry) so the box is reserved.
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 '@macrulez/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 '@macrulez/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 hazehash, 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 blur placeholder:
<VImage
src="/photo.jpg"
alt="Landscape"
:width="1200"
:height="800"
blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
/>How it works:
- On the server — a plain
<img loading="lazy">renders, with none of the placeholder machinery at all (see Layout without a wrapper); a ready preview can be added to it withssrPlaceholder, see Preview in the server-rendered HTML - On the client —
decodeBlurhash(hash, width, height)is called, the pixel data is drawn into an in-memory canvas that's never attached to the DOM viaImageData, andcanvas.toDataURL()turns it into a CSSbackground-imageright on the element that goes on to show the photo - That background stays visible while the image loads; the instant the photo decodes and paints, it covers the background — no animation, unless the opt-in
fadeInprop is set (see VImage props)
The visible "blur" comes entirely from upscaling that tiny 32px preview via background-size: cover, with no separate CSS blur filter involved.
useBlurhash() is a standalone API for when you specifically want a real, DOM-attached <canvas> you can draw onto yourself — <VImage> builds its own blurhash placeholder a different way.
A hazehash always wins over blurhash. If blurhash is supplied together with a LQIP placeholder or thumbhash, blurhash takes precedence — the LQIP/ThumbHash blur-up doesn't render at all in that case. They're mutually exclusive, not layered on top of each other.
Using the decoder directly:
import { decodeBlurhash } from '@macrulez/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; hashes are generated on the server or at build time. The package's own CLI does it: generate for a folder of source images, placeholders for the <VImage> usages already in your templates. Any other tool works too — pass the resulting string 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 string is set as a CSS
background-imageon the same element that goes on to show the full image - Once the full image loads, it covers the background instantly — a background paints underneath an element's own content, so the moment the photo has painted, the placeholder is physically hidden (no animation, unless
fadeInis set) - Before loading starts, the placeholder lives on its own element with
aria-hidden="true"— invisible to screen readers; a CSS background itself is never exposed to the accessibility tree regardless of that attribute
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 propPreview in the server-rendered HTML
By default the server sends a plain <img> and the blur appears once the page's JavaScript has run: a HazeHash, BlurHash or ThumbHash has to be decoded through a canvas, and the server has none. To show a blur from the very first paint, give the server something it doesn't need to decode — a ready data:image/png;base64,… preview — and ask for it with ssrPlaceholder:
<VImage src="/hero.webp" alt="Hero" :placeholder="heroPreview" ssr-placeholder />The server renders <img … style="background-image:url(data:…);background-size:cover">, and the browser paints it at once. It is opt-in per image: without ssr-placeholder nothing changes and no data URL is put into the HTML.
This also fixes the other half of the problem. A plain server <img src="…" loading="lazy"> is downloaded by the browser's own lazy loading, which starts a screen or two before the image, and before hydration the page is shorter, so a block far down looks close. The heavy file can arrive long before it's needed, and the blur never matters. With ssr-placeholder a lazy image is sent without its real src (a transparent pixel stands in), and the real <img> is put into a <noscript>, so crawlers and visitors without JavaScript get the full image. After hydration the image loads when it nears the viewport (rootMargin, 200 px by default), like any other lazy <VImage>. An eager image (lazy="false", priority) keeps its real src, with the preview behind it.
Where the preview comes from — every way is explicit:
- The
placeholderprop — any data URL you made yourself (see LQIP). - A build-time import —
import preview from './hero.webp?preview'(a single value), or?placeholder=…,previewfor a list; pass it as:placeholder="preview". See Build-time imports. - The registry —
placeholders: { imports: { preview: true } }makes the plugin render a preview for every imported image and register it under the imported value, so<VImage :src="hero" ssr-placeholder />is all a template needs.preview: ['src/assets/hero/', '**/cover-*.webp']limits it to the directories, files or globs you list. See A ready preview for server rendering.
What to know:
- Size. A preview is a small PNG; measured on four 300×300 images it added 3–5 KB each to the HTML (and the same to the bundle when it comes from
imports.preview). Turn it on for the images above the fold, not for every picture on the page. - Hydration. The style depends only on props and the registry, so the server and the first client render match; there is no hydration mismatch. After hydration the same preview stays under the image until it loads, with no jump to a decoded blur.
- Build-time dependency. The preview is rendered from a ThumbHash, so the
thumbhashpackage is needed at build time forimports.previewand?preview— even whenmodeisblurhash. - Transparency. The preview is a background of the image itself, so a PNG, WebP or AVIF with transparent areas shows it through.
- Not applied with
placeholderColor,placeholderMode="color"orplaceholderMode="shimmer"— those choose a different placeholder on purpose.
Placeholders manifest
A placeholders manifest maps an image src to its placeholder data, so <VImage> gets a placeholder and a size without any per-usage props. It's generated by npx vue-image-kit placeholders and registered once:
import { VImageKitPlugin } from '@macrulez/vue-image-kit'
import placeholders from './image-placeholders'
app.use(VImageKitPlugin, { placeholders })With Nuxt, pass the file path to the module instead: vueImageKit: { placeholders: './image-placeholders.ts' }.
type PlaceholderManifest = Record<string, PlaceholderEntry>
interface PlaceholderEntry {
hazehash?: string
blurhash?: string
thumbhash?: string
color?: string
width?: number
height?: number
}<VImage> looks up its src — or the fallback of a { avif, webp, fallback } source, or image.src — and uses the entry like this:
hazehash,blurhash,thumbhash,widthandheightapply wherever the matching prop isn't set — exactly as if they had been passed as props, including the size of the placeholder box, thewidth/heightof the rendered<img>, and the automaticsizes.coloris used withplaceholderMode="color", and on its own when no hash orplaceholderis available — for example, for an SVG.- Explicit props, and the
imageprop, always take precedence over the manifest.
The lookup matches the src string exactly — /images/hero.jpg, or a full CDN URL. An image imported into a component (import hero from './hero.jpg') resolves to a hashed build URL at runtime, so it can't be found this way; the placeholders command writes its values into the template instead.
The plugin provides the manifest under PLACEHOLDERS_KEY; an app that doesn't install the plugin can provide it the same way:
import { PLACEHOLDERS_KEY } from '@macrulez/vue-image-kit'
app.provide(PLACEHOLDERS_KEY, placeholders)Manifest for folders and URLs
A template can only be scanned for a src it spells out. When the path is built at runtime — :src="category.image", a list from a CMS or a data file — give the manifest whole folders instead, and every image in them is covered by its URL:
npx vue-image-kit placeholders --dir public/imagesThe Vite plugin builds the same manifest on every build, with no command to run and no file to commit. It is served as the virtual module virtual:vue-image-kit/placeholders:
// vite.config.ts
import { vueImageKit } from '@macrulez/vue-image-kit/vite'
export default defineConfig({
plugins: [vue(), vueImageKit({ generate: false, placeholders: { dirs: ['public/images'] } })],
})import placeholders from 'virtual:vue-image-kit/placeholders'
app.use(VImageKitPlugin, { placeholders })With Nuxt the module does both steps — see Module options. Each file is keyed by the URL it is served at: a file under public/ by its path from there (public/images/a.jpg → /images/a.jpg), a folder served from elsewhere by a urlPrefix ({ dir: 'src/img', urlPrefix: '/assets/img' }). Remote and CDN images are listed in urls. See Placeholders manifest from folders and Folders and URLs.
v-lazy-img and useBackgroundImage() read the manifest as well.
Client-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 '@macrulez/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 })
}encodeThumbHash(source, options?)→Promise<string>(base64). Options:maxSize(default/max100).encodeBlurhash(source, options?)→Promise<string>. Options:componentX(1–9, default4),componentY(1–9, default3),maxSize(default64).
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 '@macrulez/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>