Progressive Image Loading
Blur-up technique and other strategies for smooth image loading
Progressive Image Loading
Progressive image loading shows a low-quality version instantly, then smoothly transitions to high-quality. This creates the perception of instant loading while the full image downloads in the background.
Blur-Up Technique
The blur-up technique loads a tiny, blurred placeholder first, then replaces it with the full image.
Basic Implementation
function ProgressiveImage({
src,
placeholder,
alt,
}: Props) {
const [loaded, setLoaded] = useState(false);
const [currentSrc, setCurrentSrc] = useState(placeholder);
useEffect(() => {
const img = new window.Image();
img.src = src;
img.onload = () => {
setCurrentSrc(src);
setLoaded(true);
};
}, [src]);
return (
<img
src={currentSrc}
alt={alt}
style={{
filter: loaded ? 'blur(0)' : 'blur(20px)',
transition: 'filter 0.3s ease-out',
transform: loaded ? 'scale(1)' : 'scale(1.05)', // Slight zoom to hide blur edges
transition: 'all 0.3s ease-out',
}}
/>
);
}
// Usage
<ProgressiveImage
src="/hero-full.jpg"
placeholder="/hero-tiny.jpg" // 20x15px
alt="Hero"
/>With Intersection Observer
function LazyProgressiveImage({ src, placeholder, alt }: Props) {
const [currentSrc, setCurrentSrc] = useState(placeholder);
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
// Detect when image enters viewport
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin: '200px' } // Start loading 200px before visible
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
// Load full image when in view
useEffect(() => {
if (!inView) return;
const img = new window.Image();
img.src = src;
img.onload = () => {
setCurrentSrc(src);
setLoaded(true);
};
}, [inView, src]);
return (
<img
ref={imgRef}
src={currentSrc}
alt={alt}
style={{
filter: loaded ? 'blur(0)' : 'blur(20px)',
transform: loaded ? 'scale(1)' : 'scale(1.05)',
transition: 'all 0.3s ease-out',
}}
/>
);
}Multi-Stage Loading
Load increasingly higher quality versions.
interface ImageStage {
src: string;
quality: 'thumbnail' | 'low' | 'medium' | 'high';
}
function MultiStageImage({ stages, alt }: Props) {
const [currentStage, setCurrentStage] = useState(0);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (currentStage >= stages.length) return;
const img = new window.Image();
img.src = stages[currentStage].src;
img.onload = () => {
setLoaded(true);
// Load next stage after transition
if (currentStage < stages.length - 1) {
setTimeout(() => {
setCurrentStage(prev => prev + 1);
setLoaded(false);
}, 300);
}
};
}, [currentStage, stages]);
return (
<img
src={stages[currentStage].src}
alt={alt}
style={{
filter: loaded ? 'blur(0)' : 'blur(10px)',
opacity: loaded ? 1 : 0.8,
transition: 'all 0.3s ease-out',
}}
/>
);
}
// Usage
<MultiStageImage
stages={[
{ src: '/image-thumb.jpg', quality: 'thumbnail' }, // 20px
{ src: '/image-low.jpg', quality: 'low' }, // 400px
{ src: '/image-medium.jpg', quality: 'medium' }, // 800px
{ src: '/image-high.jpg', quality: 'high' }, // 1920px
]}
alt="Product"
/>Base64 Inline Placeholder
Embed tiny image directly in HTML (no extra request).
// Generate base64 placeholder (build time)
import sharp from 'sharp';
async function generateBase64Placeholder(imagePath: string) {
const buffer = await sharp(imagePath)
.resize(20)
.jpeg({ quality: 20 })
.toBuffer();
return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}
// Use in component
function ImageWithBase64({ src, base64Placeholder, alt }: Props) {
const [loaded, setLoaded] = useState(false);
const [currentSrc, setCurrentSrc] = useState(base64Placeholder);
useEffect(() => {
const img = new window.Image();
img.src = src;
img.onload = () => {
setCurrentSrc(src);
setLoaded(true);
};
}, [src]);
return (
<img
src={currentSrc}
alt={alt}
style={{
filter: loaded ? 'blur(0)' : 'blur(20px)',
transition: 'filter 0.3s',
}}
/>
);
}
// Usage (base64 is inline, no request!)
<ImageWithBase64
src="/hero.jpg"
base64Placeholder="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
alt="Hero"
/>Next.js Automatic Blur-Up
import Image from 'next/image';
import heroImage from './hero.jpg'; // Local import
// Next.js generates placeholder automatically
<Image
src={heroImage}
alt="Hero"
placeholder="blur" // Automatic blur-up!
priority
/>
// For remote images, provide blurDataURL
<Image
src="https://example.com/image.jpg"
alt="Remote"
width={800}
height={600}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>Progressive JPEG
Progressive JPEGs load in multiple scans (increasing quality).
# Convert to progressive JPEG with ImageMagick
convert input.jpg -interlace Plane output.jpg
# With Sharp (Node.js)
sharp('input.jpg')
.jpeg({ progressive: true, quality: 85 })
.toFile('output.jpg');
# With mozjpeg (best compression + progressive)
sharp('input.jpg')
.jpeg({ progressive: true, quality: 85, mozjpeg: true })
.toFile('output.jpg');Benefits
Baseline JPEG:
- Loads top-to-bottom
- Blocked view until fully loaded
Progressive JPEG:
- Loads in multiple passes (increasing quality)
- Low-quality version visible quickly
- Perceived performance boost
- Same file size or smallerSrcset for Responsive Loading
Load appropriate size based on viewport.
<img
srcSet="
/image-320w.jpg 320w,
/image-640w.jpg 640w,
/image-1280w.jpg 1280w,
/image-1920w.jpg 1920w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
src="/image-1280w.jpg"
alt="Responsive image"
loading="lazy"
/>BlurHash Implementation
BlurHash creates a colorful placeholder from an image hash.
npm install blurhash react-blurhash// Generate blurhash (server-side)
import { encode } from 'blurhash';
import sharp from 'sharp';
export async function generateBlurHash(imagePath: string) {
const { data, info } = await sharp(imagePath)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: 'inside' })
.toBuffer({ resolveWithObject: true });
const blurHash = encode(
new Uint8ClampedArray(data),
info.width,
info.height,
4, // componentX
4 // componentY
);
return { blurHash, width: info.width, height: info.height };
}
// Use in component
import { Blurhash } from 'react-blurhash';
function ImageWithBlurHash({ src, hash, width, height, alt }: Props) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: 'relative', width, height }}>
{/* BlurHash placeholder */}
{!loaded && (
<Blurhash
hash={hash}
width={width}
height={height}
resolutionX={32}
resolutionY={32}
punch={1}
style={{ position: 'absolute' }}
/>
)}
{/* Final image */}
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s',
}}
/>
</div>
);
}
// Usage
<ImageWithBlurHash
src="/image.jpg"
hash="LGF5]+Yk^6#M@-5c,1J5@[or[Q6."
width={800}
height={600}
alt="Product"
/>Custom Hook
function useProgressiveImage(src: string, placeholder: string) {
const [currentSrc, setCurrentSrc] = useState(placeholder);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const img = new window.Image();
img.onload = () => {
setCurrentSrc(src);
setLoading(false);
};
img.onerror = () => {
setError(new Error('Failed to load image'));
setLoading(false);
};
img.src = src;
}, [src]);
return { src: currentSrc, loading, error };
}
// Usage
function ProductImage({ fullSrc, placeholderSrc, alt }: Props) {
const { src, loading, error } = useProgressiveImage(fullSrc, placeholderSrc);
if (error) {
return <div>Failed to load image</div>;
}
return (
<img
src={src}
alt={alt}
style={{
filter: loading ? 'blur(20px)' : 'blur(0)',
transition: 'filter 0.3s',
}}
/>
);
}Fade Transition
function FadeInImage({ src, placeholder, alt }: Props) {
const [imgSrc, setImgSrc] = useState(placeholder);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const img = new window.Image();
img.src = src;
img.onload = () => {
setImgSrc(src);
setIsLoading(false);
};
}, [src]);
return (
<div style={{ position: 'relative' }}>
{/* Placeholder */}
<img
src={placeholder}
alt=""
style={{
position: 'absolute',
filter: 'blur(20px)',
opacity: isLoading ? 1 : 0,
transition: 'opacity 0.3s',
}}
/>
{/* Final image */}
<img
src={imgSrc}
alt={alt}
style={{
opacity: isLoading ? 0 : 1,
transition: 'opacity 0.3s',
}}
/>
</div>
);
}Performance Considerations
Optimize Placeholder Size
Thumbnail size: 20-40px width
Quality: 20-30%
Format: JPEG (smallest)
Base64: ~1-2 KB
Goal: < 2 KB per placeholderGenerate at Build Time
// next.config.js with sharp
const sharp = require('sharp');
async function generatePlaceholders(images) {
return Promise.all(
images.map(async (img) => {
const buffer = await sharp(img.path)
.resize(20)
.jpeg({ quality: 20 })
.toBuffer();
return {
...img,
placeholder: `data:image/jpeg;base64,${buffer.toString('base64')}`,
};
})
);
}Cache Placeholders
// Cache placeholders in memory
const placeholderCache = new Map<string, string>();
async function getPlaceholder(src: string) {
if (placeholderCache.has(src)) {
return placeholderCache.get(src)!;
}
const placeholder = await generatePlaceholder(src);
placeholderCache.set(src, placeholder);
return placeholder;
}Best Practices
- Small Placeholders: 20-40px wide, ~1-2 KB
- Smooth Transitions: 300ms ease-out
- Progressive JPEGs: For large images
- Inline Base64: For above-fold images
- Lazy Load: Below-fold images
- Srcset: Responsive image sizes
- Preload: Critical images
- Cache: Store placeholders
- Error Handling: Fallback for failed loads
- Accessibility: Alt text always
Common Pitfalls
❌ Large placeholders: Defeats purpose
✅ Tiny (20px), optimized (<2 KB)
❌ No transition: Jarring pop-in
✅ Smooth blur-to-sharp transition
❌ Runtime generation: Slow
✅ Generate at build time
❌ Loading all sizes: Wastes bandwidth
✅ Srcset + sizes for responsive
❌ Blocking render: Waiting for images
✅ Show placeholder immediately
Progressive loading creates the illusion of instant images—master this technique!