Placeholder Strategies
LQIP, blur-up, skeleton screens, and other placeholder techniques
Placeholder Strategies
Placeholders improve perceived performance by showing something immediately while content loads. They prevent layout shifts and keep users engaged.
Placeholder Types
1. Skeleton Screens
Show structure outline while content loads.
function ProductCardSkeleton() {
return (
<div className="product-card">
<div className="skeleton skeleton-image" style={{ aspectRatio: '1', width: '100%' }} />
<div className="skeleton skeleton-title" style={{ height: '24px', width: '80%', marginTop: '12px' }} />
<div className="skeleton skeleton-text" style={{ height: '16px', width: '60%', marginTop: '8px' }} />
<div className="skeleton skeleton-price" style={{ height: '20px', width: '40%', marginTop: '12px' }} />
</div>
);
}.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}2. LQIP (Low Quality Image Placeholder)
Show tiny blurred image that loads instantly.
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Tiny base64 image
/>3. Dominant Color Placeholder
Show average color of image.
function ImageWithColor({ src, color }: Props) {
const [loaded, setLoaded] = useState(false);
return (
<div
style={{
backgroundColor: color,
position: 'relative',
aspectRatio: '16 / 9',
}}
>
<img
src={src}
onLoad={() => setLoaded(true)}
style={{
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s',
}}
/>
</div>
);
}
// Usage
<ImageWithColor
src="/image.jpg"
color="#4A5568" // Dominant color
/>4. SVG Placeholder
Lightweight SVG shapes.
function ImagePlaceholder() {
return (
<svg viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="300" fill="#f0f0f0"/>
<g opacity="0.4">
<circle cx="200" cy="130" r="40" fill="#d0d0d0"/>
<path d="M120 200 L280 200 L200 100 Z" fill="#d0d0d0"/>
</g>
</svg>
);
}5. Progressive Blur (Blur-Up)
Load increasingly sharper versions.
function ProgressiveImage({ src, lqip }: Props) {
const [loaded, setLoaded] = useState(false);
const [currentSrc, setCurrentSrc] = useState(lqip);
useEffect(() => {
const img = new window.Image();
img.src = src;
img.onload = () => {
setCurrentSrc(src);
setLoaded(true);
};
}, [src]);
return (
<img
src={currentSrc}
style={{
filter: loaded ? 'blur(0)' : 'blur(20px)',
transition: 'filter 0.3s',
}}
/>
);
}Skeleton Screens
Basic Skeleton
function Skeleton({
width = '100%',
height = '20px',
borderRadius = '4px',
className = '',
}: Props) {
return (
<div
className={`skeleton ${className}`}
style={{ width, height, borderRadius }}
aria-hidden="true"
/>
);
}
// Usage
<Skeleton width="200px" height="24px" />
<Skeleton width="80%" height="16px" />Component-Specific Skeletons
// Article skeleton
function ArticleSkeleton() {
return (
<article>
<Skeleton width="60%" height="32px" /> {/* Title */}
<Skeleton width="40%" height="16px" style={{ marginTop: '8px' }} /> {/* Meta */}
<Skeleton width="100%" height="200px" style={{ marginTop: '16px' }} /> {/* Image */}
<Skeleton width="100%" height="16px" style={{ marginTop: '16px' }} />
<Skeleton width="100%" height="16px" style={{ marginTop: '8px' }} />
<Skeleton width="80%" height="16px" style={{ marginTop: '8px' }} />
</article>
);
}
// User card skeleton
function UserCardSkeleton() {
return (
<div className="user-card">
<Skeleton width="60px" height="60px" borderRadius="50%" /> {/* Avatar */}
<div style={{ marginLeft: '12px', flex: 1 }}>
<Skeleton width="120px" height="18px" /> {/* Name */}
<Skeleton width="180px" height="14px" style={{ marginTop: '6px' }} /> {/* Email */}
</div>
</div>
);
}
// Table skeleton
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<table>
<thead>
<tr>
<th><Skeleton width="100px" height="16px" /></th>
<th><Skeleton width="120px" height="16px" /></th>
<th><Skeleton width="80px" height="16px" /></th>
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
<td><Skeleton width="100%" height="16px" /></td>
<td><Skeleton width="100%" height="16px" /></td>
<td><Skeleton width="60%" height="16px" /></td>
</tr>
))}
</tbody>
</table>
);
}Animated Skeleton
/* Shimmer effect */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.skeleton {
background: linear-gradient(
to right,
#f6f7f8 0%,
#edeef1 20%,
#f6f7f8 40%,
#f6f7f8 100%
);
background-size: 1000px 100%;
animation: shimmer 2s infinite linear;
}
/* Pulse effect */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.skeleton-pulse {
background: #e0e0e0;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Wave effect */
@keyframes wave {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.skeleton-wave {
position: relative;
overflow: hidden;
background: #e0e0e0;
}
.skeleton-wave::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.5),
transparent
);
animation: wave 1.5s infinite;
}LQIP Implementation
Generate LQIP
// Node.js - Generate tiny placeholder
const sharp = require('sharp');
async function generateLQIP(imagePath) {
const buffer = await sharp(imagePath)
.resize(20) // Tiny 20px wide
.jpeg({ quality: 20 })
.toBuffer();
const base64 = buffer.toString('base64');
return `data:image/jpeg;base64,${base64}`;
}
// Usage
const lqip = await generateLQIP('public/hero.jpg');
// Result: "data:image/jpeg;base64,/9j/4AAQSkZJRg..."LQIP with Plaiceholder
npm install plaiceholderimport { getPlaiceholder } from 'plaiceholder';
import fs from 'fs/promises';
export async function getStaticProps() {
const file = await fs.readFile('./public/hero.jpg');
const { base64, img } = await getPlaiceholder(file);
return {
props: {
imageProps: {
...img,
blurDataURL: base64,
},
},
};
}
// Use in component
<Image
{...imageProps}
placeholder="blur"
alt="Hero"
/>Next.js Automatic LQIP
// Next.js generates LQIP automatically for local images
import heroImage from './hero.jpg';
<Image
src={heroImage}
alt="Hero"
placeholder="blur" // Automatic LQIP!
/>Dominant Color
Extract Dominant Color
const Vibrant = require('node-vibrant');
async function getDominantColor(imagePath) {
const palette = await Vibrant.from(imagePath).getPalette();
return palette.Vibrant.hex;
}
// Usage
const color = await getDominantColor('public/image.jpg');
// Result: "#4A5568"Use Dominant Color
export async function getStaticProps() {
const color = await getDominantColor('./public/hero.jpg');
return {
props: { heroColor: color },
};
}
function Hero({ heroColor }: Props) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ backgroundColor: heroColor }}>
<img
src="/hero.jpg"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0 }}
/>
</div>
);
}Progressive Image Loading
BlurHash
npm install blurhash react-blurhash// Generate blurhash (server-side)
import { encode } from 'blurhash';
import sharp from 'sharp';
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,
4
);
return blurHash;
}
// Use in component
import { Blurhash } from 'react-blurhash';
function ImageWithBlurHash({ src, hash }: Props) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: 'relative' }}>
{!loaded && (
<Blurhash
hash={hash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
)}
<img
src={src}
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."
/>ThumbHash
npm install thumbhashimport { thumbHashToDataURL } from 'thumbhash';
function ImageWithThumbHash({ src, thumbhash }: Props) {
const [loaded, setLoaded] = useState(false);
const placeholderUrl = thumbHashToDataURL(thumbhash);
return (
<div style={{ position: 'relative' }}>
<img
src={placeholderUrl}
style={{
position: 'absolute',
filter: 'blur(20px)',
opacity: loaded ? 0 : 1,
}}
/>
<img
src={src}
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0 }}
/>
</div>
);
}Loading States
Spinner
function Spinner({ size = 40, color = '#000' }: Props) {
return (
<div
style={{
width: size,
height: size,
border: `3px solid ${color}20`,
borderTop: `3px solid ${color}`,
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
);
}@keyframes spin {
to { transform: rotate(360deg); }
}Progress Bar
function ProgressBar({ progress }: { progress: number }) {
return (
<div className="progress-container">
<div
className="progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
);
}.progress-container {
width: 100%;
height: 4px;
background: #e0e0e0;
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4F46E5, #7C3AED);
transition: width 0.3s ease;
}Best Practices
- Match Placeholder Size: Same dimensions as final content
- Progressive Enhancement: Show something immediately
- Smooth Transitions: Fade in final content
- Accessibility: Mark placeholders with
aria-busyoraria-hidden - Minimize Layout Shift: Reserve exact space needed
- Performance: Keep placeholders lightweight
- User Feedback: Show loading state for >500ms waits
- Skeleton Over Spinner: Better for content-heavy pages
- Animate Subtly: Don't distract from content
- Test Loading States: Simulate slow connections
Common Pitfalls
❌ Generic spinners: Don't show content structure
✅ Skeleton screens match layout
❌ No placeholder: Content pops in
✅ Show something immediately
❌ Wrong dimensions: Causes layout shift
✅ Match final content size
❌ Distracting animations: Attention-grabbing
✅ Subtle, calming animations
❌ Slow placeholders: Defeats purpose
✅ Lightweight, instant display
Great placeholders make your app feel instant—even when it's not!