Image Placeholders & Loading
Recipe
Use placeholder techniques to eliminate layout shift and provide visual feedback while images load. The main approaches are blur hash encoding, CSS shimmer skeletons, and low-quality image placeholders (LQIP).
BlurHash placeholder:
npm install blurhash sharp// lib/blurhash.ts
import { encode } from "blurhash";
import sharp from "sharp";
export async function generateBlurHash(imagePath: string): Promise<string> {
const image = sharp(imagePath);
const { width, height } = await image.metadata();
// Resize to small dimensions for fast encoding
const { data, info } = await image
.resize(32, 32, { fit: "inside" })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
return encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4);
}Generate blurDataURL for next/image:
// lib/blur-data-url.ts
import sharp from "sharp";
export async function generateBlurDataURL(imagePath: string): Promise<string> {
const buffer = await sharp(imagePath)
.resize(8, 8, { fit: "inside" })
.jpeg({ quality: 50 })
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
}
// For remote images
export async function generateRemoteBlurDataURL(imageUrl: string): Promise<string> {
const res = await fetch(imageUrl);
const arrayBuffer = await res.arrayBuffer();
const buffer = await sharp(Buffer.from(arrayBuffer))
.resize(8, 8, { fit: "inside" })
.jpeg({ quality: 50 })
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
}Working Example
An image grid with blur-up loading effect:
// lib/images-with-blur.ts
import { generateRemoteBlurDataURL } from "@/lib/blur-data-url";
interface ImageData {
src: string;
alt: string;
width: number;
height: number;
}
export interface ImageWithBlur extends ImageData {
blurDataURL: string;
}
export async function getImagesWithBlur(images: ImageData[]): Promise<ImageWithBlur[]> {
return Promise.all(
images.map(async (image) => ({
...image,
blurDataURL: await generateRemoteBlurDataURL(image.src),
}))
);
}// app/gallery/page.tsx
import { getImagesWithBlur } from "@/lib/images-with-blur";
import { BlurGallery } from "./blur-gallery";
const imageData = [
{ src: "https://images.unsplash.com/photo-1506744038136-46273834b3fb", alt: "Mountain lake", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1469474968028-56623f02e42e", alt: "Forest sunlight", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1447752875215-b2761acb3c5d", alt: "Forest path", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1433086966358-54859d0ed716", alt: "Waterfall bridge", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1472214103451-9374bd1c798e", alt: "Green hills", width: 1200, height: 800 },
{ src: "https://images.unsplash.com/photo-1501854140801-50d01698950b", alt: "Aerial forest", width: 1200, height: 800 },
];
export default async function GalleryPage() {
const images = await getImagesWithBlur(imageData);
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">Gallery with Blur-Up Loading</h1>
<BlurGallery images={images} />
</main>
);
}// app/gallery/blur-gallery.tsx
"use client";
import Image from "next/image";
import { useState } from "react";
import type { ImageWithBlur } from "@/lib/images-with-blur";
export function BlurGallery({ images }: { images: ImageWithBlur[] }) {
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<div
key={image.src}
className="relative aspect-[3/2] overflow-hidden rounded-xl"
>
<Image
src={image.src}
alt={image.alt}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
placeholder="blur"
blurDataURL={image.blurDataURL}
onLoad={() =>
setLoadedImages((prev) => new Set(prev).add(image.src))
}
className={`object-cover transition-all duration-700 ${
loadedImages.has(image.src)
? "scale-100 blur-0"
: "scale-110 blur-lg"
}`}
/>
</div>
))}
</div>
);
}CSS shimmer skeleton placeholder:
// components/image-skeleton.tsx
export function ImageSkeleton({ aspectRatio = "3/2" }: { aspectRatio?: string }) {
return (
<div
className="relative overflow-hidden rounded-xl bg-gray-200"
style={{ aspectRatio }}
>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>
);
}/* Add to your global CSS or tailwind config */
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}// tailwind.config.ts - alternative: define in Tailwind config
// animation: { shimmer: "shimmer 2s infinite" }
// keyframes: { shimmer: { "100%": { transform: "translateX(100%)" } } }Deep Dive
How It Works
- BlurHash encodes image color data into a short string (typically 20-30 characters). The string is decoded client-side into a blurred preview image. The encoding is done at build time or on the server.
- blurDataURL is a base64-encoded tiny image (usually 8x8 or 10x10 pixels) that
next/imagedisplays as a CSS background while the full image loads. Static imports auto-generate this. - LQIP (Low Quality Image Placeholder) serves a very small version of the actual image (around 20px wide) and scales it up with CSS blur. This is what the blur-up technique implements.
- Shimmer skeletons use a CSS animation to show a pulsing gradient, indicating content is loading without any image data.
- The
next/imagecomponent handles the transition from placeholder to loaded image automatically when usingplaceholder="blur". Custom transitions require theonLoadcallback.
Variations
BlurHash canvas rendering (client-side):
"use client";
import { decode } from "blurhash";
import { useEffect, useRef } from "react";
interface BlurHashCanvasProps {
hash: string;
width: number;
height: number;
className?: string;
}
export function BlurHashCanvas({ hash, width, height, className }: BlurHashCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const pixels = decode(hash, width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const imageData = ctx.createImageData(width, height);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}, [hash, width, height]);
return <canvas ref={canvasRef} width={width} height={height} className={className} />;
}Static import with automatic blur (simplest approach):
import Image from "next/image";
import heroImage from "@/public/images/hero.jpg";
// blurDataURL is automatically generated at build time
export function Hero() {
return (
<Image
src={heroImage}
alt="Hero"
placeholder="blur"
priority
className="h-[60vh] w-full object-cover"
/>
);
}Progressive loading with multiple sizes:
"use client";
import { useState } from "react";
interface ProgressiveImageProps {
tinyUrl: string;
fullUrl: string;
alt: string;
}
export function ProgressiveImage({ tinyUrl, fullUrl, alt }: ProgressiveImageProps) {
const [loaded, setLoaded] = useState(false);
return (
<div className="relative overflow-hidden">
{/* Tiny blurred version - always visible initially */}
<img
src={tinyUrl}
alt=""
aria-hidden
className={`absolute inset-0 h-full w-full scale-110 object-cover blur-xl transition-opacity duration-500 ${
loaded ? "opacity-0" : "opacity-100"
}`}
/>
{/* Full resolution image */}
<img
src={fullUrl}
alt={alt}
onLoad={() => setLoaded(true)}
className={`relative h-full w-full object-cover transition-opacity duration-500 ${
loaded ? "opacity-100" : "opacity-0"
}`}
/>
</div>
);
}TypeScript Notes
- The
blurhashlibrary exportsencode(server-side, needs pixel data) anddecode(client-side, returns pixel array). sharpis a Node.js-only library. Use it only in Server Components, API routes, or build scripts.next/imageonLoadis typed asReact.ReactEventHandler<HTMLImageElement>.
Gotchas
sharpcannot run in the browser or in Edge Runtime. Only use it in Node.js Server Components or build-time scripts.- BlurHash encoding at request time adds latency. Generate blur hashes at build time or cache them in a database alongside image metadata.
- Very small
blurDataURLimages (under 4x4 pixels) may produce visible banding instead of a smooth blur effect. Use 8x8 or 10x10 for better results. - The
placeholder="blur"prop innext/imagewith remote images requires ablurDataURL. Without it, Next.js throws an error. - CSS
blur()filter on large images can cause performance issues on low-powered devices. Keep the blur applied only to the tiny placeholder, not the full-resolution image. - Shimmer skeletons should match the dimensions and aspect ratio of the final content to prevent layout shift.
- The
onLoadevent fires when the image is decoded, which may be slightly after it becomes visible. For pixel-perfect transitions, consideronLoadingComplete(deprecated in Next.js 14+) or the nativedecode()API.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| BlurHash | Tiny payload (30 chars), smooth blur | Requires encoding step, client-side decoding |
| blurDataURL (next/image) | Built-in, automatic for static imports | Manual generation for remote images |
| CSS shimmer skeleton | No image data needed, pure CSS | Generic, no color preview |
| LQIP (tiny image) | Actual image preview, simple | Larger than BlurHash string, extra request |
| Dominant color | Smallest payload (one hex value) | No spatial information, flat color only |
| ThumbHash | Better quality than BlurHash, includes alpha | Newer, less ecosystem support |
FAQs
What are the four main placeholder techniques covered on this page?
- BlurHash -- encodes color data into a short string, decoded client-side.
- blurDataURL -- a base64-encoded tiny image used by
next/image. - LQIP -- a low-quality version of the actual image scaled up with CSS blur.
- Shimmer skeleton -- a CSS animation with a pulsing gradient, no image data needed.
How does placeholder="blur" work differently for static vs. remote images?
- Static imports automatically generate
blurDataURLat build time -- no extra work needed. - Remote images require you to provide a
blurDataURLprop manually. - Without
blurDataURL, usingplaceholder="blur"on a remote image throws an error.
What size should the tiny image be for an effective blurDataURL?
- Use 8x8 or 10x10 pixels for best results.
- Very small images (under 4x4) produce visible banding instead of a smooth blur.
- The base64 string is still tiny (under 1KB).
Gotcha: Where can sharp run, and where will it fail?
sharpis a Node.js-only library.- It works in Server Components, API routes, and build-time scripts.
- It cannot run in the browser or in Edge Runtime.
How does the blur-up loading effect work in the gallery example?
className={`object-cover transition-all duration-700 ${
loadedImages.has(image.src) ? "scale-100 blur-0" : "scale-110 blur-lg"
}`}The image starts scaled up and blurred, then transitions to normal on load via the onLoad callback.
When should you generate BlurHash values -- at request time or build time?
- Generate at build time or cache in a database alongside image metadata.
- Encoding at request time adds latency to every page load.
How do you create a CSS shimmer skeleton placeholder?
<div className="relative overflow-hidden rounded-xl bg-gray-200" style={{ aspectRatio: "3/2" }}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite]
bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>Gotcha: Can CSS blur() on large images cause performance problems?
- Yes, on low-powered devices it can cause jank.
- Apply blur only to the tiny placeholder image, never to the full-resolution image.
What TypeScript types are relevant for the BlurHash and sharp libraries?
blurhashexportsencode(server-side, needs pixel data) anddecode(client-side, returnsUint8ClampedArray).sharpis Node.js-only and returns typedBufferand metadata objects.next/imageonLoadis typed asReact.ReactEventHandler<HTMLImageElement>.
How does the progressive loading technique differ from BlurHash?
- Progressive loading uses an actual tiny version of the image (e.g., 20px wide) scaled up with CSS blur.
- BlurHash uses a compact string (20-30 characters) that must be decoded into pixel data.
- Progressive loading is simpler but requires an extra image request; BlurHash is smaller but needs a decoding step.
Why should shimmer skeletons match the final content dimensions?
- Mismatched dimensions cause layout shift (CLS) when the real content loads.
- Always set the same aspect ratio on the skeleton as the final image.