Implementing LQIP (Low Quality Image Placeholders) in an Astro Blog

发表于 2025-12-20 17:52 1687 字 9 min read

cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

本文提出了一种轻量级的CSS-only低质量图片占位符(LQIP)方案,通过构建时使用Sharp提取图片四象点主色,生成紧凑的18字符十六进制字符串,运行时解码为CSS线性渐变,实现零运行时开销和极小体积(约42KB/1000张)。方案支持内部图片自动处理,外部图片通过纯色降级,并优化了存储体积与可读性平衡,适用于静态站点生成器如Astro。

This article has been machine-translated from Chinese. The translation may contain inaccuracies or awkward phrasing. If in doubt, please refer to the original Chinese version.

This article was written with AI assistance, for record-keeping purposes

Initially, I wanted to implement something similar to Minimal CSS-only blurry image placeholders — a CSS-only LQIP (Low Quality Image Placeholder) for the blog, using a single CSS custom property --lqip to encode a blurry preview of the image.

The technical principle behind that article is using a 20-bit integer to encode image information (8-bit Oklab base color + 12-bit luminance component), then decoding it in CSS through bitwise operations (mod(), round(down), pow(), etc.), rendering a blur effect using overlaid radial gradients, with quadratic easing for smooth transitions.

I chose a simpler implementation, giving up on the CSS-only approach, since I planned to start with the blog’s internal images first and optimize external images within articles later. Here’s the final result:

You just need to run nr generate:lqips and it will generate a lqips.json file under assets. If this file doesn’t exist, no placeholders are provided.

The following is an AI-generated record with minor edits.

What is LQIP

LQIP (Low Quality Image Placeholder) is an image loading optimization technique that displays a low-quality placeholder before the high-resolution image finishes loading, preventing blank areas or layout shifts on the page.

Common LQIP implementations include:

  1. Thumbnail approach: Generate a tiny image (e.g., 20x20), scale it up and display it blurred during loading
  2. BlurHash: Encode the image as a short string and decode/render it on the client side
  3. Dominant color approach: Extract the image’s dominant color as a solid background
  4. Gradient approach: Extract multiple color points to generate CSS gradients

This article introduces the gradient approach, which extracts the dominant colors from four quadrants of an image at build time and generates a CSS linear gradient as a placeholder. The advantages of this approach are:

  • Zero runtime overhead: Pure CSS implementation, no JavaScript decoding needed
  • Extremely small data size: Only 18 characters of storage per image
  • Natural visual effect: Gradients are more representative of the original image’s color distribution than solid colors

Solution Design

The overall architecture consists of three parts:

flowchart LR
    A[Build-time generation script<br/>generateLqips.ts] --> B[JSON data file<br/>lqips.json]
    B --> C[Runtime utility functions<br/>lqip.ts]

    A -.-> D[sharp image processing<br/>Extract quadrant colors]
    C -.-> E[Astro component calls<br/>Generate CSS gradients]

Data Format Design

To minimize JSON size, we use a compact storage format:

{
  "cover/1.webp": "87a3c4c2dfefbddae9",
  "cover/2.webp": "6e3b38ae7472af7574"
}

Each value is 18 hexadecimal characters, consisting of 3 colors (with the # prefix removed):

  • Characters 0-5: Top-left color
  • Characters 6-11: Top-right color
  • Characters 12-17: Bottom-right color

At runtime, these are decoded into CSS gradients:

linear-gradient(135deg, #87a3c4 0%, #c2dfef 50%, #bddae9 100%)

(PS: This is a trade-off that sacrifices a bit of readability while maintaining some editability. Please don’t judge me — I know someone will ask why I didn’t use a binary format.)

Build-Time Generation Script

src/scripts/generateLqips.ts handles processing all images at build time:

import sharp from 'sharp';
import { glob } from 'glob';
import fs from 'fs/promises';
import path from 'path';
import chalk from 'chalk';

// --------- Configuration ---------
const IMAGE_GLOB = 'public/img/**/*.{webp,jpg,jpeg,png}';
const OUTPUT_FILE = 'src/assets/lqips.json';

// --------- Type Definitions ---------
interface RgbColor {
  r: number;
  g: number;
  b: number;
}

type LqipMap = Record<string, string>;

// --------- Color Utility Functions ---------

/**
 * RGB to hexadecimal string
 */
function rgbToHex(rgb: RgbColor): string {
  const toHex = (n: number) =>
    Math.round(Math.max(0, Math.min(255, n)))
      .toString(16)
      .padStart(2, '0');
  return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
}

// --------- Image Processing ---------

/**
 * Process a single image and generate a compact color string
 */
async function processImage(imagePath: string): Promise<string | null> {
  try {

    // Resize to 2x2 to get 4 quadrant colors
    const resized = await sharp(imagePath).resize(2, 2, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });

    const channels = resized.info.channels;
    const data = resized.data;

    // Extract 4 colors (top-left, top-right, bottom-left, bottom-right)
    const colors: string[] = [];
    for (let i = 0; i < 4; i++) {
      const offset = i * channels;
      const rgb: RgbColor = {
        r: data[offset],
        g: data[offset + 1],
        b: data[offset + 2],
      };
      colors.push(rgbToHex(rgb));
    }

    // Compact storage: 3 colors (top-left, top-right, bottom-right), # prefix removed
    // Used to generate a 135deg diagonal gradient
    const compact = `${colors[0].slice(1)}${colors[1].slice(1)}${colors[3].slice(1)}`;

    return compact;
  } catch (error) {
    console.error(chalk.red(`  Error processing ${imagePath}:`), error);
    return null;
  }
}

/**
 * Convert file path to short key name
 */
function filePathToKey(filePath: string): string {
  // public/img/cover/1.webp → cover/1.webp
  return filePath.replace(/^public\/img\//, '');
}

// --------- Main Function ---------
async function main() {
  const startTime = Date.now();

  console.log(chalk.cyan('=== LQIP Generator ===\n'));

  const files = await glob(IMAGE_GLOB);
  if (!files.length) {
    console.log(chalk.yellow('No image files found.'));
    return;
  }
  console.log(chalk.blue(`Found ${files.length} images\n`));

  const lqips: LqipMap = {};
  let processed = 0;

  for (const file of files) {
    process.stdout.write(`\r  Processing ${processed + 1}/${files.length}...`);

    const compact = await processImage(file);
    if (compact !== null) {
      const key = filePathToKey(file);
      lqips[key] = compact;
      processed++;
    }
  }

  const dir = path.dirname(OUTPUT_FILE);
  await fs.mkdir(dir, { recursive: true });
  await fs.writeFile(OUTPUT_FILE, JSON.stringify(lqips, null, 2) + '\n');

  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  console.log(chalk.green(`\n\nDone! Generated LQIP for ${processed} images in ${elapsed}s`));
  console.log(chalk.cyan(`Output saved to: ${OUTPUT_FILE}`));
}

main();

Core Principle: 2x2 Scaling

Sharp’s resize(2, 2, { fit: 'fill' }) scales the image down to 2x2 pixels, where each pixel represents the average color of one quarter of the original image:

flowchart LR
    subgraph orig[Original Image]
        direction TB
        subgraph row1[" "]
            A1["Region A"]
            B1["Region B"]
        end
        subgraph row2[" "]
            C1["Region C"]
            D1["Region D"]
        end
    end

    orig -.->|2x2 scaling| scaled

    subgraph scaled[2x2 Scaled Result]
        direction TB
        subgraph row3[" "]
            A2["A"]
            B2["B"]
        end
        subgraph row4[" "]
            C2["C"]
            D2["D"]
        end
    end

We select three colors — A (top-left), B (top-right), and D (bottom-right) — to generate a 135-degree diagonal gradient. This covers the image’s main color distribution while avoiding redundant data storage.

Runtime Utility Functions

src/lib/lqip.ts provides the runtime API:

// Import build-time generated LQIP data
let lqips: Record<string, string> = {};

try {
  const lqipData = await import('@assets/lqips.json');
  lqips = lqipData.default as Record<string, string>;
} catch {
  // Silently fail if file doesn't exist (before first build)
}

/**
 * Convert image path to LQIP key
 */
function imagePathToKey(imagePath: string): string {
  return imagePath.replace(/^\/img\//, '');
}

/**
 * Get the LQIP gradient CSS for an image
 */
export function getLqipGradient(imagePath: string): string | undefined {
  const key = imagePathToKey(imagePath);
  const compact = lqips[key];
  if (compact?.length !== 18) return undefined;

  // Decode compact format: 18 characters → 3 hex colors
  const c1 = `#${compact.slice(0, 6)}`;
  const c2 = `#${compact.slice(6, 12)}`;
  const c3 = `#${compact.slice(12, 18)}`;

  return `linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c3} 100%)`;
}

/**
 * Check if an image is external
 */
export function isExternalImage(imagePath: string): boolean {
  return imagePath.startsWith('http://') || imagePath.startsWith('https://');
}

/**
 * Get LQIP inline style
 */
export function getLqipStyle(imagePath: string): string | undefined {
  if (isExternalImage(imagePath)) {
    return undefined;
  }
  const gradient = getLqipGradient(imagePath);
  return gradient ? `background-image:${gradient}` : undefined;
}

/**
 * Get LQIP props (for components)
 */
export function getLqipProps(imagePath: string): { style?: string; class?: string } {
  if (isExternalImage(imagePath)) {
    return { class: 'lqip-fallback' };
  }

  const style = getLqipStyle(imagePath);
  return style ? { style } : {};
}

External Image Fallback

For external images (user-specified cover URLs), we cannot process them at build time, so we provide a CSS fallback:

/* src/styles/components/lqip.css */
.lqip-fallback {
  background-color: hsl(var(--muted));
}

Using in Astro Components

Post Card Cover

Applying LQIP in PostItemCard.astro:

---
import { getLqipProps } from '@lib/lqip';

const finalCover = cover ?? randomCover ?? defaultCoverList[0];
const lqipProps = getLqipProps(finalCover);
---

<a href={href} style={lqipProps.style} class={cn('relative overflow-hidden', lqipProps.class)}>
  <Image src={finalCover} width={600} height={186} loading="lazy" alt="post cover" class="h-full w-full object-cover" />
</a>

Page Banner

Applying LQIP in Cover.astro:

---
import { getLqipStyle } from '@lib/lqip';

const bannerLqipStyle = getLqipStyle('/img/site_header_1920.webp');
---

<div class="relative h-full w-full" style={bannerLqipStyle} id="banner-box">
  <Image src="/img/site_header_1920.webp" width={1920} height={1080} alt="cover" class="h-full w-full object-cover" />
</div>

Storage Optimization

During iteration, we went through multiple rounds of size optimization:

VersionFormat ExamplePer Entry
v1 Full CSSlinear-gradient(135deg, #87a3c4 0%, ...)80 bytes
v2 Compact Color87a3c4c2dfefbddae947 bytes
v3 Short Keyscover/1.webp -> same as above42 bytes

The final version is approximately 50% smaller than v1. Estimated for 1000 images:

  • v1: ~80KB
  • v3: ~42KB

For more aggressive optimization, you could also consider:

  • Base64 encoding: 3 colors = 9 bytes -> 12 characters in Base64
  • Binary format: Completely avoid JSON overhead

However, considering readability and debugging convenience, the current JSON format is already a good balance.

Build Integration

Add a script to package.json:

{
  "scripts": {
    "generate:lqips": "npx tsx src/scripts/generateLqips.ts"
  }
}

This can be configured as a pre-build step in CI/CD, or run manually after adding new images.

Effect Demonstration

Using a red leaf image as an example:

  • Original image path: /img/cover/2.webp
  • LQIP data: 6e3b38ae7472af7574
  • Decoded gradient: linear-gradient(135deg, #6e3b38 0%, #ae7472 50%, #af7574 100%)

The gradient colors accurately reflect the warm red tones of the original image, providing a good visual preview before the image loads.

Summary

This article introduced a lightweight LQIP implementation:

  1. Build time: Use sharp to extract quadrant dominant colors and generate compact color strings
  2. Runtime: Decode into CSS gradients used as container backgrounds
  3. Fallback strategy: External images use solid-color placeholders

The core advantages of this approach are zero runtime overhead and extremely small data size, making it very suitable for static site generators like Astro, Next.js, and similar frameworks.

Refs

This article is being revised as needed. Feel free to leave a comment if you find any errors or omissions.

喜欢的话,留下你的评论吧~

© 2020 - 2026 cos @cosine
Powered by theme astro-koharu · Inspired by Shoka