Image placeholders: LQIP, BlurHash, ThumbHash and dominant color
A good placeholder makes your site feel faster without changing a single byte of your images. Here's what techniques exist, how much they weigh and how to implement them properly.
The problem: empty slots and jumping content
When a browser paints a page, text appears almost instantly, but each image needs its own download. Meanwhile, users see one of two scenarios:
- An empty slot (white or gray) that suddenly fills with the photo. It works, but it feels abrupt and "slow".
- Nothing, and when the image arrives it pushes everything below it down. This is the worst case: besides looking bad, it hurts CLS (Cumulative Layout Shift), one of the Core Web Vitals.
The second scenario is always fixed by declaring width and height (or aspect-ratio in CSS), and that's the first thing you should do. The first one is improved with a placeholder: filling the reserved slot with a tiny approximation of the image.
The techniques, from lightest to most faithful
1. Dominant color (7 bytes)
A single color extracted from the image and applied as the container's background:
<img src="/photo.avif" width="1200" height="800" alt="…" style="background:#4a6b8a">
This is what Pinterest, Google Images and Unsplash do in their grids. No JavaScript, no extra requests, and it can be generated in the database when the image is uploaded. For listings with lots of thumbnails it's hard to beat.
2. CSS gradient (≈60 bytes)
Two or more colors sampled by region and combined with linear-gradient. It conveys composition ("sky on top, sand below") better than a flat color and is still pure CSS.
3. Base64 LQIP (100-300 bytes)
Low Quality Image Placeholder: the image itself shrunk to 10-20 px, encoded as a data URI and scaled up with a CSS blur. It's Medium's technique and what Next.js uses with placeholder="blur". It clearly reads as "the same photo, blurred", which makes for a very natural transition.
<img src="/photo.avif" width="1200" height="800" alt="…"
style="background: url('data:image/webp;base64,UklGR…') center/cover">
4. BlurHash (≈20-30 characters)
Developed by Wolt, it encodes the image as a few frequency components (a heavily reduced discrete cosine transform) in a compact text string. It needs JavaScript to decode into a canvas, but in return it's short enough to store in any database column and travel through an API unnoticed. There are implementations for web, iOS, Android, Flutter, Python, Go…
5. ThumbHash (≈30-45 characters)
Created by Evan Wallace in 2023 as an evolution of BlurHash. At a similar size it achieves more faithful colors, stores the aspect ratio and supports transparency, which makes it ideal for cut-out products or logos.
Quick comparison
| Technique | Size | Needs JS | Transparency | Fidelity |
|---|---|---|---|---|
| Dominant color | 7 B | No | No | Low |
| CSS gradient | ~60 B | No | No | Low-medium |
| Base64 LQIP | 100-300 B | No | Yes (PNG/WebP) | High |
| BlurHash | ~28 B | Yes | No | Medium |
| ThumbHash | ~35 B | Yes (or a precomputed data URL) | Yes | Medium-high |
Implementation in Next.js
With statically imported images, Next.js computes the LQIP at build time and a single prop is enough:
import Image from "next/image";
import hero from "@/public/hero.jpg";
<Image src={hero} alt="…" placeholder="blur" priority />
With remote images (CMS, S3, Cloudinary), Next.js can't download them at build time, so you have to provide the blurDataURL yourself. The right approach is to generate it once, when the image is uploaded, and store it next to the URL:
<Image
src={post.image.url}
width={post.image.width}
height={post.image.height}
alt={post.image.alt}
placeholder="blur"
blurDataURL={post.image.blurDataURL}
/>
If you don't have an automated process, the placeholder generator gives you the blurDataURL for a batch of images as JSON ready to import.
Implementation with BlurHash in React
import { Blurhash } from "react-blurhash";
import { useState } from "react";
function Photo({ src, hash, width, height, alt }) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: "relative", aspectRatio: `${width} / ${height}` }}>
{!loaded && <Blurhash hash={hash} width="100%" height="100%" style={{ position: "absolute", inset: 0 }} />}
<img src={src} alt={alt} width={width} height={height} loading="lazy"
onLoad={() => setLoaded(true)} style={{ width: "100%", height: "auto" }} />
</div>
);
}
Note that the container has aspect-ratio: BlurHash doesn't know the image's proportions and, without them, the placeholder wouldn't reserve the right space.
Mistakes that make performance worse
- Placeholders that are too big. A 64 px LQIP can exceed 2 KB. Multiplied by 40 thumbnails, that's 80 KB of HTML the browser must download and parse before painting. Stick to 16-20 px.
- Decoding BlurHash on the main thread for hundreds of images. Each decode is cheap, but hundreds at once block interaction (INP). Decode at 32 × 32 px and scale with CSS, never at the final size.
- Delaying the LCP image. A placeholder doesn't replace loading the main image early. The LCP image should have
fetchpriority="high"and neverloading="lazy"; the placeholder is an add-on, not an excuse. - Long fade-in animations. A 1-second transition makes the image look sharp 1 second later. 200-300 ms is enough.
- Forgetting dark mode. A light gray placeholder stands out in a dark theme. Dominant color and LQIP adapt to the image on their own, not to the theme.
Which technique to choose
- Blog or content site with Next.js:
placeholder="blur"for local images and ablurDataURLstored in the CMS for remote ones. - Online store with product grids: dominant color in listings and LQIP on product pages.
- Mobile app and website sharing an API: BlurHash or ThumbHash in the JSON.
- Images with transparency: ThumbHash, or LQIP as PNG/WebP.
Frequently asked questions
Do placeholders improve LCP?
Not directly: LCP is measured when the final image is painted, not the placeholder. What they improve is perceived speed and, combined with dimensions, they prevent CLS.
Does Google index the placeholder instead of the image?
No. Google indexes the URL in the src or srcset attribute. The placeholder lives in a CSS background or a canvas and doesn't compete with the real image.
Where should I store placeholders?
Next to the image metadata: in your database, in your Markdown frontmatter or in the CMS. Generating them on every request wastes resources.