🖼️ Placeholder Images Creation Guide

Placeholder images покращують perceived performance та user experience під час завантаження контенту. Замість порожнього білого простору або "broken image" icons, користувачі бачать skeleton screens, blur-up placeholders або low-quality image previews. Це створює враження швидшого сайту та зменшує bounce rate. Цей гід охоплює всі типи placeholders та їх імплементацію.

🎯 Types of Placeholders

1. Skeleton Screens

Gray boxes that mimic content structure

Use case: Image galleries, cards, lists

Pros: Modern, progressively loads

2. LQIP (Low Quality Image Placeholder)

Tiny blurred version (5-20KB)

Use case: Hero images, large photos

Pros: Shows actual content, smooth transition

3. Base64 Inline SVG

Embedded SVG placeholder

Use case: Critical above-fold images

Pros: No HTTP request, instant display

4. Color Dominant Placeholder

Solid color або gradient matching image

Use case: Background images

Pros: Minimal размір, smooth transition

💻 Skeleton Screen Implementation

HTML Structure:

<div class="mushroom-card">
  <div class="skeleton skeleton-image"></div>
  <div class="skeleton skeleton-title"></div>
  <div class="skeleton skeleton-text"></div>
  <div class="skeleton skeleton-text"></div>
</div>

CSS Animation:

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

.skeleton-image {
  width: 100%;
  height: 200px;
  margin-bottom: 15px;
}

.skeleton-title {
  height: 24px;
  width: 70%;
  margin-bottom: 10px;
}

.skeleton-text {
  height: 16px;
  width: 100%;
  margin-bottom: 8px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

JavaScript Loading:

// Replace skeleton з real content
function loadMushroomCards() {
  fetch('/api/mushrooms')
    .then(res => res.json())
    .then(data => {
      const container = document.querySelector('.gallery');
      container.innerHTML = ''; // Remove skeletons
      
      data.forEach(mushroom => {
        const card = createMushroomCard(mushroom);
        container.appendChild(card);
      });
    });
}

// Show skeleton while loading
document.addEventListener('DOMContentLoaded', () => {
  showSkeletons(6); // Show 6 skeleton cards
  loadMushroomCards();
});

🌫️ LQIP (Blur-Up) Implementation

Generating LQIP:

// Sharp (Node.js) - Create tiny blurred version
const sharp = require('sharp');

sharp('mushroom-original.jpg')
  .resize(20, 20, { fit: 'inside' }) // Tiny: 20x20px
  .blur(5)
  .toBuffer()
  .then(buffer => {
    const base64 = buffer.toString('base64');
    console.log(`data:image/jpeg;base64,${base64}`);
  });

Progressive Loading HTML:

<div class="progressive-image" 
     style="background-image:url(data:image/jpeg;base64,/9j/4AAQ...)">
  <img 
    src="mushroom-20x20-blur.jpg" 
    data-src="mushroom-full.jpg"
    class="progressive-image__full"
    alt="Psilocybe cubensis"
    loading="lazy"
  />
</div>

CSS Blur-Up Effect:

.progressive-image {
  position: relative;
  overflow: hidden;
  background-size: cover;
  background-position: center;
  filter: blur(10px);
  transform: scale(1.05); /* Prevent blur edges */
}

.progressive-image__full {
  width: 100%;
  opacity: 0;
  transition: opacity 0.5s ease;
}

.progressive-image__full.loaded {
  opacity: 1;
}

.progressive-image.loaded {
  filter: blur(0);
}

JavaScript Blur-Up Logic:

const images = document.querySelectorAll('.progressive-image__full');

images.forEach(img => {
  const fullSrc = img.dataset.src;
  
  // Preload full image
  const fullImg = new Image();
  fullImg.src = fullSrc;
  
  fullImg.onload = () => {
    img.src = fullSrc;
    img.classList.add('loaded');
    img.parentElement.classList.add('loaded');
  };
});

🎨 SVG Placeholders

Geometric SVG Placeholder:

<svg viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" style="stop-color:#8B4513;stop-opacity:1" />
      <stop offset="100%" style="stop-color:#D2691E;stop-opacity:1" />
    </linearGradient>
  </defs>
  
  <!-- Background -->
  <rect width="400" height="300" fill="#f5f5f5"/>
  
  <!-- Mushroom silhouette -->
  <ellipse cx="200" cy="150" rx="100" ry="40" fill="url(#grad)"/>
  <rect x="180" y="150" width="40" height="80" fill="#8B4513"/>
  
  <!-- Loading text -->
  <text x="200" y="260" font-family="Arial" font-size="14" 
        text-anchor="middle" fill="#999">Loading...</text>
</svg>

Minimal Icon Placeholder:

const mushroomPlaceholder = `
<svg width="200" height="200" viewBox="0 0 100 100">
  <circle cx="50" cy="50" r="40" fill="#e0e0e0"/>
  <path d="M30,50 Q50,20 70,50" fill="#bdbdbd"/>
  <rect x="45" y="50" width="10" height="30" fill="#bdbdbd"/>
</svg>`;

// Inline as base64
const encoded = btoa(mushroomPlaceholder);
imgElement.src = `data:image/svg+xml;base64,${encoded}`;

🌈 Dominant Color Placeholders

Extract Dominant Color (Python):

from PIL import Image
import colorsys

def get_dominant_color(image_path):
    img = Image.open(image_path)
    img = img.resize((50, 50))  # Downsample
    pixels = img.getdata()
    
    # Calculate average RGB
    r_avg = sum(p[0] for p in pixels) // len(pixels)
    g_avg = sum(p[1] for p in pixels) // len(pixels)
    b_avg = sum(p[2] for p in pixels) // len(pixels)
    
    return f"rgb({r_avg}, {g_avg}, {b_avg})"

color = get_dominant_color('mushroom.jpg')
print(color)  # "rgb(142, 98, 67)" (earthy brown)

Use Dominant Color:

<div class="image-container" 
     style="background-color: rgb(142, 98, 67);">
  <img src="mushroom.jpg" alt="..." loading="lazy">
</div>

<style>
.image-container {
  position: relative;
  overflow: hidden;
}

.image-container img {
  width: 100%;
  display: block;
  opacity: 0;
  transition: opacity 0.3s;
}

.image-container img.loaded {
  opacity: 1;
}
</style>

⚡ Advanced: Blurhash Implementation

Blurhash — compact representation of placeholder (20-30 characters encode entire blurred image)

Generate Blurhash:

// Install: npm install blurhash
const { encode } = require('blurhash');
const sharp = require('sharp');

async function generateBlurhash(imagePath) {
  const imageBuffer = await sharp(imagePath)
    .raw()
    .ensureAlpha()
    .resize(32, 32, { fit: 'inside' })
    .toBuffer({ resolveWithObject: true });
  
  const blurhash = encode(
    new Uint8ClampedArray(imageBuffer.data),
    imageBuffer.info.width,
    imageBuffer.info.height,
    4,
    4
  );
  
  return blurhash;
}

// Example output: "LGF5]+Yk^6#M@-5c,1J5@[or[Q6."

Decode та Display:

<!-- Include blurhash library -->
<script src="https://unpkg.com/blurhash/dist/blurhash.min.js"></script>

<canvas id="placeholder" width="400" height="300"></canvas>
<img id="full-image" src="" data-blurhash="LGF5]+Yk^6#M@-5c,1J5@[or[Q6." />

<script>
const canvas = document.getElementById('placeholder');
const img = document.getElementById('full-image');
const blurhash = img.dataset.blurhash;

// Decode blurhash до canvas
const pixels = blurhash.decode(blurhash, 400, 300);
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(400, 300);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);

// Load full image
img.onload = () => {
  canvas.style.display = 'none';
  img.style.display = 'block';
};
img.src = 'mushroom-full.jpg';
</script>

📋 Best Practices

✅ DO

  • Match placeholder aspect ratio до real image
  • Use subtle animations (не відволікайте)
  • Inline critical placeholders (base64)
  • Smooth transition (opacity fade)

❌ DON'T

  • Large base64 images (> 10KB)
  • Jarring layout shifts
  • Too aggressive shimmer animation
  • Forget width/height attributes

Performance Metrics:

  • LQIP size: < 2KB (base64 або ultra-compressed)
  • Blurhash size: ~30 bytes (optimal!)
  • SVG placeholder: < 1KB
  • Skeleton CSS: ~0.5KB

🛠️ Automated Placeholder Generation

Build Script (Node.js):

const sharp = require('sharp');
const { encode } = require('blurhash');
const fs = require('fs');

async function generatePlaceholders(inputDir, outputFile) {
  const files = fs.readdirSync(inputDir);
  const placeholders = {};

  for (const file of files) {
    if (!file.match(/\.(jpg|jpeg|png)$/)) continue;
    
    const path = `${inputDir}/${file}`;
    
    // 1. LQIP (20x20 blur)
    const lqip = await sharp(path)
      .resize(20, 20)
      .blur(5)
      .toBuffer();
    
    // 2. Dominant color
    const { dominant } = await sharp(path).stats();
    
    // 3. Blurhash
    const { data, info } = await sharp(path)
      .raw()
      .ensureAlpha()
      .resize(32, 32)
      .toBuffer({ resolveWithObject: true });
    
    const blurhash = encode(
      new Uint8ClampedArray(data),
      info.width,
      info.height,
      4,
      4
    );
    
    placeholders[file] = {
      lqip: `data:image/jpeg;base64,${lqip.toString('base64')}`,
      dominantColor: `rgb(${dominant.r}, ${dominant.g}, ${dominant.b})`,
      blurhash: blurhash
    };
  }
  
  fs.writeFileSync(outputFile, JSON.stringify(placeholders, null, 2));
  console.log(`✅ Generated placeholders for ${Object.keys(placeholders).length} images`);
}

generatePlaceholders('./images', './placeholders.json');

Usage:

// Load placeholders data
const placeholders = require('./placeholders.json');

// Render image з placeholder
function renderMushroomImage(filename) {
  const placeholder = placeholders[filename];
  
  return `
    <div class="progressive-image" 
         style="background-color: ${placeholder.dominantColor}">
      <img 
        src="${placeholder.lqip}"
        data-src="/images/${filename}"
        data-blurhash="${placeholder.blurhash}"
        alt="Mushroom photo"
        loading="lazy"
      />
    </div>
  `;
}