Skip to content

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.

vue
<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:

bash
npm install hazehash

Without 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:

vue
<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):

ts
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):

ts
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-color

Or let VImage do it via placeholder-mode="color" (see Props).

placeholder prop — equivalent when you already have the data URL:

vue
<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):

bash
npm install thumbhash --save-dev

npx vue-image-kit generate \
  --input ./src/images \
  --manifest ./src/assets/images.ts \
  --thumbhash

The manifest will include a thumbhash field for each image alongside hazehash, blurhash and placeholder.

Or generate manually in Node.js:

ts
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 prop

Blurhash 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:

vue
<VImage
  src="/photo.jpg"
  alt="Landscape"
  :width="1200"
  :height="800"
  blurhash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
/>

How it works:

  1. 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 with ssrPlaceholder, see Preview in the server-rendered HTML
  2. 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 via ImageData, and canvas.toDataURL() turns it into a CSS background-image right on the element that goes on to show the photo
  3. 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 fadeIn prop 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:

ts
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.

vue
<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-image on 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 fadeIn is 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):

ts
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 prop

Preview 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:

vue
<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 placeholder prop — any data URL you made yourself (see LQIP).
  • A build-time import — import preview from './hero.webp?preview' (a single value), or ?placeholder=…,preview for 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 thumbhash package is needed at build time for imports.preview and ?preview — even when mode is blurhash.
  • 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" or placeholderMode="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:

ts
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' }.

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, width and height apply wherever the matching prop isn't set — exactly as if they had been passed as props, including the size of the placeholder box, the width/height of the rendered <img>, and the automatic sizes.
  • color is used with placeholderMode="color", and on its own when no hash or placeholder is available — for example, for an SVG.
  • Explicit props, and the image prop, 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:

ts
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:

bash
npx vue-image-kit placeholders --dir public/images

The 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:

ts
// vite.config.ts
import { vueImageKit } from '@macrulez/vue-image-kit/vite'

export default defineConfig({
  plugins: [vue(), vueImageKit({ generate: false, placeholders: { dirs: ['public/images'] } })],
})
ts
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.

ts
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/max 100).
  • encodeBlurhash(source, options?) → Promise<string>. Options: 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.

vue
<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>