Progressive image reveal
A blurred placeholder holds the frame at the right aspect ratio and clears once the real image has decoded.
Grade: ARuntimes: css · gsapStatus: live

Intent
Every other media pattern here animates on scroll. This one animates on *decode*, and the difference matters: a scroll-triggered fade on an image that has not finished loading animates an empty box, so the reveal lands before there is anything to reveal. Holding the frame with a placeholder also means the layout never moves, which is the cheapest CLS win available on an image-heavy page.
Implementation
The shipped component. The real image is never hidden — the decorative veil is the animated element, so a hydration or GSAP failure degrades to a stale overlay rather than to invisible content.
Dependencies: gsap@^3.13, @gsap/react@^2, next@^16
"use client";
import Image from "next/image";
import { useRef, useState } from "react";
import { DUR, EASE, gsap, prefersReduced, useGSAP } from "@/lib/gsap";
export function ProgressiveImage({ src, alt, blurDataURL, className }) {
const scope = useRef(null);
const [decoded, setDecoded] = useState(false);
useGSAP(
() => {
const veil = scope.current?.querySelector("[data-veil]");
if (!decoded || !veil) return;
if (prefersReduced()) return void gsap.set(veil, { autoAlpha: 0 });
const tween = gsap.to(veil, { autoAlpha: 0, duration: DUR.micro, ease: EASE.power4Out });
return () => void tween.kill();
},
{ scope, dependencies: [decoded] },
);
return (
<div ref={scope} className={className} style={{ position: "relative", overflow: "hidden" }}>
<Image src={src} alt={alt} fill onLoad={() => setDecoded(true)} className="object-cover" />
{blurDataURL ? (
<img data-veil aria-hidden src={blurDataURL} alt=""
className="pointer-events-none absolute inset-0 h-full w-full scale-105 object-cover blur-xl" />
) : null}
<noscript><style>{`[data-veil]{display:none}`}</style></noscript>
</div>
);
}Use it when
- Any page carrying more than a handful of photographs — grids, indexes, case-study bodies.
- Above the fold, where a blank frame during decode is the first thing a visitor sees.
- Anywhere the image is remote or unoptimised and its decode time is genuinely unpredictable.
Avoid it when
- Images small enough to decode within a frame — the veil becomes a flash rather than a resolve.
- Transparent assets. A blurred rectangle behind a cut-out logo reads as a rendering fault.
- Alongside a clip-reveal on the same element; two entrance treatments on one image cancel out.
Accessibility
- The placeholder is decorative: `aria-hidden` and an empty `alt`. Only the real image carries the description.
- Under reduced motion the placeholder is removed in one step rather than tweened — the end state is identical either way.
- Never invert this by hiding the real image and fading it in. A JavaScript failure would then leave permanently invisible content; layering the decorative element on top means the worst case is a stale overlay, and `<noscript>` removes even that.
Performance
- The frame must declare its aspect ratio, or the placeholder swap reintroduces the layout shift this pattern exists to prevent.
- Keep the placeholder under about 1KB and inline it — a network round-trip for the thing that covers a network round-trip is self-defeating.
- Animate opacity only. The overscale is a static transform, not an animated one.
- Fire on the load event rather than on scroll, so a cached image resolves immediately instead of waiting for a trigger.
Knobs
- blurDataURL
- The placeholder. A 4-16px encode, or a flat gradient. Default: none — the veil is skipped without one
- priority
- Skip lazy loading for above-the-fold images. Default: false
- sizes
- Responsive candidate widths, so the browser fetches one image rather than the largest. Default: (max-width: 768px) 100vw, 50vw
Composition
Conflicts with
Instruction for Claude Code
Build a progressive image component. Reserve the frame with a fixed aspect ratio so nothing shifts. Render the real image at full opacity and layer a blurred, aria-hidden, slightly overscaled low-quality placeholder on top of it; clear the placeholder with a short opacity tween when the image's load event fires. Do not hide the real image and fade it in — a script failure would then leave invisible content. Add a <noscript> rule that removes the placeholder. Under prefers-reduced-motion, remove it in one step instead of tweening.