Image Placeholder Generator: 6 Techniques With Code
A user clicks through to your page, and for 800 milliseconds the hero image is a blank white rectangle. The text beneath it jumps down when the image finally loads. Cumulative Layout Shift spikes. The user bounces. You had one job.
An image placeholder generator solves this by creating a tiny preview — a blurred version, a color swatch, an SVG trace — that occupies the exact space of the final image while it loads. The preview is small enough to inline directly in your HTML (often under 50 bytes), so the browser renders it instantly. No layout shift. No blank rectangles. The real image fades in on top.
This is fundamentally different from placeholder image services like picsum.photos, which provide stock stand-in images during development. Here we are generating lightweight previews from your actual images — the production technique that makes pages feel instant.
Why Image Placeholders Matter
Two numbers make the case:
Cumulative Layout Shift (CLS). Google's Core Web Vital that measures visual stability. Every image without explicit dimensions or a placeholder contributes to CLS. A CLS score above 0.1 hurts rankings. Placeholders hold the space, keeping CLS at zero for image elements.
Perceived performance. Users perceive a page with a blurred preview as loading faster than a page with blank space — even when the actual load time is identical. A study from Akamai found that a 100ms delay in load time drops conversion rates by 7%. Placeholders buy you perceived speed for free.
Beyond metrics, placeholders communicate intent. A blurred preview tells the user "an image is coming" — blank space says "maybe something is broken." That distinction matters on slow connections, where images might take several seconds.
Try it yourself
Reduce file size without visible quality loss — free, instant, no signup. Your images never leave your browser.
Technique Comparison
Here is every major approach, side by side:
| Technique | Output Size | Visual Quality | Encoding Speed | Browser Support | Best For |
|---|---|---|---|---|---|
| BlurHash | 20–30 bytes | Soft color blur | Fast (~5ms) | All (canvas decode) | Hero images, thumbnails, any image grid |
| ThumbHash | 25–35 bytes | Color blur + alpha | Fast (~5ms) | All (canvas decode) | Images with transparency (logos, PNGs) |
| LQIP (resize + blur) | 200–800 bytes (base64) | Recognizable shapes | Fast (~10ms with sharp) | All (native <img>) |
Blog post headers, editorial photos |
| SQIP (SVG trace) | 800–2,000 bytes | Geometric shapes | Slow (~200ms) | All (inline SVG) | Illustrations, graphics, product shots |
| Dominant Color | 7 bytes (hex) | Single color fill | Instant (~1ms) | All (CSS background) | Minimal design, large grids, thumbnails |
| CSS Gradient | 50–120 bytes | 2–4 color regions | Fast (~3ms) | All (CSS) | Cards, avatars, when you want more than one color but less than a blur |
The choice depends on your tolerance for payload size versus visual fidelity. BlurHash and ThumbHash give the best ratio — recognizable color blurs in under 35 bytes.
BlurHash
Created by Dag Ågren for Wolt, BlurHash encodes an image into a short string of 20–30 characters using discrete cosine transforms. The string decodes to a soft, blurred preview on a canvas element. It is the most widely adopted image placeholder generator technique, used by Mastodon, Signal, and Wolt in production.
License: MIT
Generating a BlurHash (Node.js)
npm install blurhash@2.0.5 sharp@0.33.4
// generate-blurhash.mjs
import { encode } from "blurhash";
import sharp from "sharp";
async function generateBlurHash(imagePath) {
const { data, info } = await sharp(imagePath)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: "inside" })
.toBuffer({ resolveWithObject: true });
const hash = encode(
new Uint8ClampedArray(data),
info.width,
info.height,
4, // x components
3 // y components
);
console.log(hash); // e.g., "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
return hash;
}
generateBlurHash("./hero.jpg");
The 4, 3 component values control detail level. More components = more detail = longer string. For most images, 4x3 is the sweet spot — enough color variation to be recognizable, short enough to inline everywhere.
Decoding in the Browser
import { decode } from "blurhash";
function blurHashToDataURL(hash, width = 32, height = 32) {
const pixels = decode(hash, width, height);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(width, height);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
}
// Usage: set as img src or background-image
const dataURL = blurHashToDataURL("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
Decode to a small canvas (32x32 is plenty) and scale up with CSS — the blur is intentional, so more pixels just waste CPU cycles.
ThumbHash
Created by Evan Wallace (co-creator of esbuild and Figma CTO), ThumbHash improves on BlurHash in two ways: it preserves alpha transparency and produces slightly more detailed previews. If your images include PNGs with transparent backgrounds — logos, product cutouts, UI elements — ThumbHash is the better choice.
License: MIT
Generating a ThumbHash
npm install thumbhash@0.1.1 sharp@0.33.4
// generate-thumbhash.mjs
import * as ThumbHash from "thumbhash";
import sharp from "sharp";
async function generateThumbHash(imagePath) {
const { data, info } = await sharp(imagePath)
.ensureAlpha()
.resize(100, 100, { fit: "inside" })
.raw()
.toBuffer({ resolveWithObject: true });
const hash = ThumbHash.rgbaToThumbHash(
info.width,
info.height,
data
);
// Convert to base64 for storage
const base64 = Buffer.from(hash).toString("base64");
console.log(base64); // e.g., "3OcRJYB4d3h/iIeHeEh3eIhw+j3A"
return base64;
}
generateThumbHash("./logo.png");
Decoding in the Browser
import * as ThumbHash from "thumbhash";
function thumbHashToDataURL(base64Hash) {
const hash = Uint8Array.from(atob(base64Hash), c => c.charCodeAt(0));
const { w, h, rgba } = ThumbHash.thumbHashToRGBA(hash);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(w, h);
imageData.data.set(rgba);
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
}
ThumbHash produces its own width and height from the encoded data, so the decode function does not need you to specify dimensions. The output is typically a few bytes larger than BlurHash but carries more visual information.
LQIP (Low Quality Image Placeholder)
LQIP is the oldest and most intuitive approach: resize the image to something tiny (16–40px wide), blur it slightly, and base64-encode the result directly into your HTML. The browser renders it natively as an <img> — no JavaScript decode step needed.
Medium, Facebook (in its early implementation), and countless WordPress sites use LQIP. The technique is sometimes called "blur up" because the tiny image is displayed blurred and then transitions to the full-resolution version.
Generating LQIP with sharp
npm install sharp@0.33.4
sharp (Apache-2.0 license) is the standard Node.js image processing library. It wraps libvips, which is significantly faster than ImageMagick for batch operations.
// generate-lqip.mjs
import sharp from "sharp";
async function generateLQIP(imagePath, width = 20) {
const buffer = await sharp(imagePath)
.resize(width)
.blur(2)
.jpeg({ quality: 30 })
.toBuffer();
const base64 = `data:image/jpeg;base64,${buffer.toString("base64")}`;
console.log(`Size: ${buffer.length} bytes`);
console.log(base64);
return base64;
}
generateLQIP("./hero.jpg");
A 20px-wide JPEG at quality 30 typically comes out to 300–600 bytes after base64 encoding. That is larger than BlurHash (20–30 bytes) but requires zero client-side JavaScript to display.
Usage in HTML
<img
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..."
data-src="/images/hero.jpg"
alt="Mountain landscape at sunset"
width="1200"
height="800"
style="filter: blur(10px); transition: filter 0.3s"
loading="lazy"
>
When the real image loads (via your lazy loading script or Intersection Observer), swap data-src into src and remove the blur filter. The transition creates a smooth "sharpen" effect.
The tradeoff: LQIP base64 strings are 10–30x larger than BlurHash strings. For a page with 50 thumbnails, that adds 15–30 KB to your HTML. For a hero image or a blog post with 3–5 images, the difference is negligible.
SQIP (SVG-Based Image Placeholder)
SQIP, created by Tobias Baldauf at Akamai, traces the image into geometric SVG primitives — triangles, ellipses, and polygons that approximate the image's major shapes. The result is an SVG string you can inline directly into your HTML.
SQIP produces the most visually interesting placeholders. Where BlurHash gives you a soft color field, SQIP gives you abstract geometric art that clearly resembles the original image. The cost is size (800–2,000 bytes) and encoding speed (~200ms per image).
When to Use SQIP
SQIP shines for editorial and marketing content where the placeholder itself is part of the visual experience — portfolio sites, magazine layouts, product landing pages. For high-volume image grids (e-commerce catalogs, social feeds), the encoding time and payload size make BlurHash or ThumbHash more practical.
The original SQIP library (sqip@1.0.0) depends on Primitive, a Go binary. For build pipelines, install it globally:
npm install -g sqip@1.0.0
sqip --input hero.jpg --output hero-placeholder.svg --numberOfPrimitives 20
The --numberOfPrimitives flag controls detail level. Start with 15–20 primitives and adjust based on your visual quality needs versus payload size.
Dominant Color Extraction
The simplest image placeholder generator: extract the dominant color from an image and use it as a CSS background. Instagram used this approach for years — before the image loaded, you saw the primary color of the photo as a colored rectangle.
Extracting Dominant Color with sharp
// dominant-color.mjs
import sharp from "sharp";
async function getDominantColor(imagePath) {
const { dominant } = await sharp(imagePath).stats();
const hex = `#${[dominant.r, dominant.g, dominant.b]
.map(c => c.toString(16).padStart(2, "0"))
.join("")}`;
console.log(hex); // e.g., "#2d5a3f"
return hex;
}
getDominantColor("./hero.jpg");
Usage in CSS
<div
style="background-color: #2d5a3f; aspect-ratio: 1200/800"
>
<img
src="/images/hero.jpg"
alt="Forest canopy"
width="1200"
height="800"
loading="lazy"
style="opacity: 0; transition: opacity 0.3s"
onload="this.style.opacity=1"
>
</div>
At 7 bytes (a hex color string), this is the smallest possible placeholder. It holds layout space, prevents CLS, and provides a color hint — all with zero JavaScript and zero additional network requests. The limitation is obvious: one color does not communicate much about the image content.
CSS Gradient Placeholders
A step up from dominant color: extract 2–4 key colors and arrange them as a CSS gradient. This communicates more about the image (sky-to-ground, light-to-dark) while staying under 120 bytes.
Generating a Gradient Placeholder
// gradient-placeholder.mjs
import sharp from "sharp";
async function generateGradient(imagePath) {
// Resize to 2x2 and extract pixel colors
const { data } = await sharp(imagePath)
.resize(2, 2, { fit: "fill" })
.raw()
.toBuffer({ resolveWithObject: true });
const colors = [];
for (let i = 0; i < data.length; i += 3) {
colors.push(`rgb(${data[i]}, ${data[i + 1]}, ${data[i + 2]})`);
}
// Create a 4-point gradient
const gradient = `linear-gradient(
135deg,
${colors[0]} 0%,
${colors[1]} 50%,
${colors[2]} 75%,
${colors[3]} 100%
)`;
console.log(gradient);
return gradient;
}
generateGradient("./hero.jpg");
This samples a 2x2 grid from the image — top-left, top-right, bottom-left, bottom-right — and maps them into a diagonal gradient. The result is a rough color map of the image that loads in zero network requests.
Implementation Patterns
React Component (BlurHash)
// ImageWithPlaceholder.jsx
import { useEffect, useRef, useState } from "react";
import { decode } from "blurhash";
export function ImageWithPlaceholder({ hash, src, alt, width, height }) {
const canvasRef = useRef(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!canvasRef.current || !hash) return;
const pixels = decode(hash, 32, 32);
const ctx = canvasRef.current.getContext("2d");
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}, [hash]);
return (
<div style={{ position: "relative", aspectRatio: `${width}/${height}` }}>
<canvas
ref={canvasRef}
width={32}
height={32}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
opacity: loaded ? 0 : 1,
transition: "opacity 0.4s ease-out",
}}
/>
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
onLoad={() => setLoaded(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: loaded ? 1 : 0,
transition: "opacity 0.4s ease-out",
}}
/>
</div>
);
}
// Usage:
// <ImageWithPlaceholder
// hash="LEHV6nWB2yk8pyo0adR*.7kCMdnj"
// src="/images/hero.jpg"
// alt="Mountain landscape"
// width={1200}
// height={800}
// />
Vanilla JavaScript (Any Technique)
<img
class="lazy-placeholder"
src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
data-src="/images/hero.jpg"
alt="Product photo"
width="600"
height="400"
style="filter: blur(10px); transition: filter 0.3s, opacity 0.3s"
>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.addEventListener("load", () => {
img.style.filter = "none";
img.removeAttribute("data-src");
}, { once: true });
observer.unobserve(img);
});
}, { rootMargin: "200px" });
document.querySelectorAll(".lazy-placeholder").forEach(img => {
observer.observe(img);
});
</script>
This pairs well with LQIP but works with any base64 placeholder. The rootMargin: "200px" starts loading images 200px before they enter the viewport, so most users never see the placeholder at all on fast connections.
For a deeper dive on the lazy loading side of this equation, see the lazy loading images guide.
Measuring CLS Impact
Placeholders exist to eliminate layout shift. Verify they actually do.
Using Web Vitals Library
npm install web-vitals@4.2.4
import { onCLS } from "web-vitals";
onCLS((metric) => {
console.log("CLS:", metric.value);
console.log("Entries:", metric.entries);
// Send to your analytics
navigator.sendBeacon("/api/vitals", JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
}));
});
Target: CLS below 0.1 is "good" per Google. With proper placeholders and explicit width/height attributes on every <img>, you should hit 0.00–0.02 consistently.
Testing: Run Lighthouse in Chrome DevTools with "Slow 3G" throttling. This exaggerates the gap between placeholder render and image load, making layout shifts visible. If CLS is zero on slow 3G, it is zero everywhere.
Before optimizing images for CLS, make sure they are properly sized for their display context. Serving a 4000px image in a 400px container wastes bandwidth and slows everything down. Use Pixotter's resize tool to match your output dimensions, then run your placeholder generator on the correctly-sized files.
Choosing the Right Technique
Decision framework:
Use BlurHash when you need the smallest possible payload, your images are opaque (no transparency), and you are comfortable adding a JavaScript decode step. This covers most web applications — product grids, blog headers, social feeds.
Use ThumbHash when your images have transparency. If you are building an app that displays user avatars, logos, or product shots on transparent backgrounds, ThumbHash preserves that alpha channel where BlurHash cannot.
Use LQIP when you want zero client-side JavaScript. The base64 <img> renders natively in every browser, every email client, and every RSS reader. Good for blogs, newsletters, and server-rendered pages where you control the HTML but not the JavaScript environment.
Use SQIP when visual quality of the placeholder itself matters. Portfolio sites, photography galleries, and editorial layouts where the loading state is part of the design. Accept the larger payload and slower encoding time.
Use dominant color when you have high-volume grids (hundreds of thumbnails) and want the absolute minimum overhead. Instagram's approach: a colored rectangle that transitions to the real image. Clean, fast, tiny.
Use CSS gradient when you want more color information than a single swatch but less overhead than an encoded blur. Good for card-based layouts and avatars.
For most projects, start with BlurHash. It has the largest ecosystem (libraries for React, Vue, Swift, Kotlin, Python, Go, Rust), the best size-to-quality ratio, and battle-tested production usage. Switch to ThumbHash if you hit the transparency limitation.
Build Pipeline Integration
Generate placeholders at build time or upload time — not at request time. Here is a script that processes an entire directory:
// generate-placeholders.mjs
import { encode } from "blurhash";
import sharp from "sharp";
import { readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
const IMAGE_DIR = "./public/images";
const OUTPUT = "./src/data/placeholders.json";
async function processImage(filePath) {
const { data, info } = await sharp(filePath)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: "inside" })
.toBuffer({ resolveWithObject: true });
return encode(new Uint8ClampedArray(data), info.width, info.height, 4, 3);
}
async function main() {
const files = (await readdir(IMAGE_DIR))
.filter(f => /\.(jpe?g|png|webp|avif)$/i.test(f));
const placeholders = {};
for (const file of files) {
const hash = await processImage(join(IMAGE_DIR, file));
placeholders[file] = hash;
console.log(`${file}: ${hash}`);
}
await writeFile(OUTPUT, JSON.stringify(placeholders, null, 2));
console.log(`Wrote ${files.length} hashes to ${OUTPUT}`);
}
main();
Run this in your CI/CD pipeline or as a post-upload hook. The output JSON maps filenames to BlurHash strings, which your frontend imports and uses at render time.
Before feeding images into this pipeline, compress them first — placeholder generation is faster on optimized files, and your final page load is smaller too.
Frequently Asked Questions
Do image placeholders affect SEO?
Positively. Placeholders improve CLS scores, which is a direct Google ranking signal. They also improve perceived load time, which reduces bounce rate — an indirect ranking signal. Pair them with proper alt text, width/height attributes, and image SEO best practices for the full effect.
What is the difference between LQIP and BlurHash?
LQIP is a tiny JPEG or WebP of the actual image, base64-encoded and displayed as a native <img>. BlurHash is a mathematical encoding (discrete cosine transform) stored as a short ASCII string and decoded to a canvas. BlurHash is 10–30x smaller but requires JavaScript to render. LQIP works everywhere, including email and RSS.
How much does a BlurHash string add to my page size?
A typical BlurHash is 20–30 characters. For a page with 50 images, that adds roughly 1–1.5 KB total to your HTML payload. For comparison, a single 1x1 tracking pixel GIF is 43 bytes.
Can I generate placeholders for WebP and AVIF images?
Yes. Every technique listed here works with WebP and AVIF as input formats — sharp 0.33 supports both natively. The placeholder itself is format-independent: BlurHash produces an ASCII string, LQIP can output JPEG or WebP, and the others produce CSS or SVG.
Should I use loading="lazy" with placeholders?
Yes, always. The placeholder handles the visual experience during load, while loading="lazy" prevents the browser from fetching offscreen images at all. They solve different problems and work together. See the lazy loading images guide for full implementation details.
Which technique does Next.js use?
Next.js Image component uses LQIP by default when you set placeholder="blur". It generates a 10px-wide blurred JPEG at build time via sharp and inlines the base64 string. You can also pass a custom blurDataURL if you prefer BlurHash or ThumbHash.
Do placeholders work with responsive images and srcset?
Yes. The placeholder occupies the container at its aspect-ratio (set via width/height attributes or CSS), and the responsive image loads at the appropriate size for the viewport. The technique is independent of which srcset candidate the browser selects.
What is the right image size for a website placeholder?
Match the placeholder's aspect ratio to the final image dimensions, not the pixel count. A BlurHash decoded to 32x32 pixels and scaled via CSS to 1200x800 looks identical to one decoded at 64x64 — the blur hides the resolution difference. Keep the decode small and let CSS handle the scaling.
Wrapping Up
An image placeholder generator turns a blank loading state into a polished transition. BlurHash covers 90% of use cases at 20–30 bytes per image. ThumbHash handles transparency. LQIP works without JavaScript. Dominant color is the zero-overhead option. Pick the one that fits your constraints, generate at build time, and measure CLS to confirm it works.
The placeholder is one piece of the image performance puzzle. Compress your images with Pixotter before generating placeholders — smaller source files mean faster loads, smaller LQIP strings, and less work for your build pipeline.
Try it yourself
Resize to exact dimensions for any platform — free, instant, no signup. Your images never leave your browser.