Rejouice — loading0

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
A sample project thumbnail resolving from its placeholder
Blur-up on decode — press Replay to re-arm

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

next-tailwind-motion
"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

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.