
Placeholder 136 B, inline in the HTMLFull image 14.4 KB at 1200px WebP
The popular “blur-up” trick (Medium, Next.js placeholder="blur", Gatsby): the page never shows an
empty box or a layout jump. The visitor sees the right colours and shapes from the first paint,
and the detail sharpens in when the real file arrives.
How it works
- At build time, sharp shrinks the image to 16px wide and encodes it as WebP. That’s a few
hundred bytes, inlined into the HTML as a
data:URI, so it costs no extra request. - The wrapper reserves the exact space with
aspect-ratio(no layout shift) and paints the placeholder underneath, blurred withfilter: blur(20px). It’s scaled slightly so the blur doesn’t fade out at the edges. - The real image is a normal responsive
<Image>:srcsetin WebP, lazy-loaded. It starts invisible and blurred. A one-line inlineonloadmarks it loaded, and CSS transitions it to sharp and opaque. - No JavaScript?
@media (scripting: none)shows the image directly. Reduced motion? Only a quick fade, without the blur or zoom.
Usage
---
import BlurImage from '@/content/components/blur-image/BlurImage.astro';
import photo from '../assets/photo.jpg';
---
<BlurImage src={photo} alt="…" />
<!-- above the fold: load immediately -->
<BlurImage src={photo} alt="…" loading="eager" sizes="100vw" />
| Prop | Default | Notes |
|---|---|---|
src |
— | A local image import (ImageMetadata) |
alt |
— | Required |
widths |
[480, 800, 1200, 1600] |
Widths larger than the original are skipped |
sizes |
(min-width: 1080px) 1080px, 100vw |
Match your layout |
loading |
lazy |
Use eager for the hero / LCP image |
placeholderWidth |
16 |
8–32: more detail, more bytes |
Elsewhere
- Next.js:
<Image src={photo} placeholder="blur" />does this for static imports out of the box. - Remote images: the placeholder needs the file at build time. For CMS images, store a ThumbHash (about 25 bytes) with the asset and decode it to a data URI.
BlurImage.astro
---
/**
* Blur-up image ("LQIP"): a tiny blurred copy of the image is inlined into the HTML, so the
* frame shows the right colours instantly; the real, responsive image fades in sharp on top
* once it has downloaded.
*
* - Placeholder: generated at build time with sharp, ~16px wide WebP as a base64 data URI
* (a few hundred bytes, no extra request).
* - Image: astro:assets <Image> with responsive widths, WebP, lazy-loaded by default.
* - Reveal: one inline `onload` attribute, no bundle. Without JavaScript (or with reduced
* motion) the image is simply shown.
*/
import type { ImageMetadata } from 'astro';
import { Image } from 'astro:assets';
import sharp from 'sharp';
interface Props {
src: ImageMetadata;
alt: string;
/** Widths to generate for srcset. */
widths?: number[];
sizes?: string;
loading?: 'lazy' | 'eager';
/** Width of the inline placeholder in px; bigger = more detail, more bytes. */
placeholderWidth?: number;
class?: string;
}
const {
src,
alt,
widths = [480, 800, 1200, 1600],
sizes = '(min-width: 1080px) 1080px, 100vw',
loading = 'lazy',
placeholderWidth = 16,
class: className,
} = Astro.props;
// `fsPath` points at the original file for local images imported with `import img from './x.jpg'`.
const file = (src as ImageMetadata & { fsPath: string }).fsPath;
const tiny = await sharp(file).resize(placeholderWidth).webp({ quality: 40 }).toBuffer();
const placeholder = `data:image/webp;base64,${tiny.toString('base64')}`;
---
<div class:list={['blur-image', className]} style={`--placeholder: url(${placeholder}); aspect-ratio: ${src.width} / ${src.height};`}>
<Image
src={src}
alt={alt}
widths={widths.filter((w) => w <= src.width)}
sizes={sizes}
loading={loading}
decoding="async"
onload="this.dataset.loaded = ''"
/>
</div>
<style>
.blur-image {
position: relative;
overflow: hidden;
isolation: isolate;
}
/* The placeholder, blurred and slightly scaled so the blur doesn't fade at the edges. */
.blur-image::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: var(--placeholder) center / cover no-repeat;
filter: blur(20px);
transform: scale(1.15);
}
.blur-image img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
filter: blur(16px);
transform: scale(1.04);
transition:
opacity 0.5s ease,
filter 0.7s ease,
transform 0.7s ease;
}
.blur-image img[data-loaded] {
opacity: 1;
filter: none;
transform: none;
}
/* No JavaScript, no onload: just show the image. */
@media (scripting: none) {
.blur-image img {
opacity: 1;
filter: none;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.blur-image img {
transition: opacity 0.2s ease;
filter: none;
transform: none;
}
}
</style>