Front-end Engineering Lab

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 plaiceholder
import { 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 thumbhash
import { 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

  1. Match Placeholder Size: Same dimensions as final content
  2. Progressive Enhancement: Show something immediately
  3. Smooth Transitions: Fade in final content
  4. Accessibility: Mark placeholders with aria-busy or aria-hidden
  5. Minimize Layout Shift: Reserve exact space needed
  6. Performance: Keep placeholders lightweight
  7. User Feedback: Show loading state for >500ms waits
  8. Skeleton Over Spinner: Better for content-heavy pages
  9. Animate Subtly: Don't distract from content
  10. 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!

On this page