React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

blurhashplaceholderskeletonLQIPprogressive-loading

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/image displays 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/image component handles the transition from placeholder to loaded image automatically when using placeholder="blur". Custom transitions require the onLoad callback.

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 blurhash library exports encode (server-side, needs pixel data) and decode (client-side, returns pixel array).
  • sharp is a Node.js-only library. Use it only in Server Components, API routes, or build scripts.
  • next/image onLoad is typed as React.ReactEventHandler<HTMLImageElement>.

Gotchas

  • sharp cannot 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 blurDataURL images (under 4x4 pixels) may produce visible banding instead of a smooth blur effect. Use 8x8 or 10x10 for better results.
  • The placeholder="blur" prop in next/image with remote images requires a blurDataURL. 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 onLoad event fires when the image is decoded, which may be slightly after it becomes visible. For pixel-perfect transitions, consider onLoadingComplete (deprecated in Next.js 14+) or the native decode() API.

Alternatives

ApproachProsCons
BlurHashTiny payload (30 chars), smooth blurRequires encoding step, client-side decoding
blurDataURL (next/image)Built-in, automatic for static importsManual generation for remote images
CSS shimmer skeletonNo image data needed, pure CSSGeneric, no color preview
LQIP (tiny image)Actual image preview, simpleLarger than BlurHash string, extra request
Dominant colorSmallest payload (one hex value)No spatial information, flat color only
ThumbHashBetter quality than BlurHash, includes alphaNewer, 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 blurDataURL at build time -- no extra work needed.
  • Remote images require you to provide a blurDataURL prop manually.
  • Without blurDataURL, using placeholder="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?
  • sharp is 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?
  • blurhash exports encode (server-side, needs pixel data) and decode (client-side, returns Uint8ClampedArray).
  • sharp is Node.js-only and returns typed Buffer and metadata objects.
  • next/image onLoad is typed as React.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.