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:
- Thumbnail approach: Generate a tiny image (e.g., 20x20), scale it up and display it blurred during loading
- BlurHash: Encode the image as a short string and decode/render it on the client side
- Dominant color approach: Extract the image’s dominant color as a solid background
- 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:
| Version | Format Example | Per Entry |
|---|---|---|
| v1 Full CSS | linear-gradient(135deg, #87a3c4 0%, ...) | 80 bytes |
| v2 Compact Color | 87a3c4c2dfefbddae9 | 47 bytes |
| v3 Short Keys | cover/1.webp -> same as above | 42 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:
- Build time: Use sharp to extract quadrant dominant colors and generate compact color strings
- Runtime: Decode into CSS gradients used as container backgrounds
- 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
- CSS-only LQIP - Original inspiration, an advanced approach using 20-bit integer encoding
- sharp documentation - High-performance Node.js image processing library
- BlurHash - Another LQIP approach
This article is being revised as needed. Feel free to leave a comment if you find any errors or omissions.
喜欢的话,留下你的评论吧~