Skip to content
← Components

Blur-up image

Shows a tiny blurred placeholder instantly, then fades the real image in sharp once it has loaded. LQIP with zero extra requests.

  • image
  • performance
  • lqip
  • placeholder
  • lazy-loading
Purple mountains at dusk with a glowing sun over a lake
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

  1. 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.
  2. The wrapper reserves the exact space with aspect-ratio (no layout shift) and paints the placeholder underneath, blurred with filter: blur(20px). It’s scaled slightly so the blur doesn’t fade out at the edges.
  3. The real image is a normal responsive <Image>: srcset in WebP, lazy-loaded. It starts invisible and blurred. A one-line inline onload marks it loaded, and CSS transitions it to sharp and opaque.
  4. 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

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>
esc