Return

Next.js Blur-up Image

A decode-aware reveal built on real Next.js image optimisation.

next-blur-image
Why it helps: Next.js generates responsive image variants and serves the right one for the layout. This component reserves space, keeps a tiny LQIP visible, and waits for decode before revealing the final bitmap so the interaction stays stable and intentional.

Installation

pnpm add motion

Source

components/interior/blur-up-image.tsx
"use client";

import Image, { type ImageProps } from "next/image";
import { motion, useReducedMotion } from "motion/react";
import { type SyntheticEvent, useCallback, useState } from "react";

export type BlurUpStatus = "idle" | "loading" | "ready" | "error";

export type BlurUpImageProps = Omit<
  ImageProps,
  "src" | "alt" | "width" | "height" | "fill" | "className" | "placeholder" | "blurDataURL" | "onLoad" | "onError"
> & {
  src?: ImageProps["src"];
  alt: string;
  width: number;
  height: number;
  placeholder?: string;
  color?: string;
  blur?: number;
  radius?: number;
  className?: string;
  imageClassName?: string;
  onReady?: () => void;
  onError?: () => void;
};

const REVEAL = { duration: 0.76, ease: [0.22, 1, 0.36, 1] } as const;

const sourceKey = (src: BlurUpImageProps["src"]) => {
  if (!src) return "pending";
  if (typeof src === "string") return src;
  return ("default" in src ? src.default : src).src;
};

const staticBlurData = (src: BlurUpImageProps["src"]) => {
  const image = typeof src === "object" && src !== null && "default" in src ? src.default : src;
  if (typeof image !== "object" || image === null || !("blurDataURL" in image)) return undefined;
  return typeof image.blurDataURL === "string" ? image.blurDataURL : undefined;
};

export function BlurUpImage(props: BlurUpImageProps) {
  return <BlurUpImageFrame key={sourceKey(props.src)} {...props} />;
}

function BlurUpImageFrame({
  src,
  alt,
  width,
  height,
  placeholder,
  color,
  blur = 18,
  radius = 12,
  className,
  imageClassName,
  onReady,
  onError,
  ...imageProps
}: BlurUpImageProps) {
  const [status, setStatus] = useState<BlurUpStatus>(src ? "loading" : "idle");
  const ready = status === "ready";
  const failed = status === "error";
  const reducedMotion = useReducedMotion();
  const lqip = placeholder ?? staticBlurData(src);

  const reveal = useCallback(async (event: SyntheticEvent<HTMLImageElement>) => {
    try {
      await event.currentTarget.decode?.();
      setStatus("ready");
      onReady?.();
    } catch {
      setStatus("error");
      onError?.();
    }
  }, [onError, onReady]);

  return (
    <div
      aria-busy={Boolean(src) && !ready && !failed}
      className={\`relative w-full overflow-hidden bg-muted \${className ?? ""}\`}
      style={{ aspectRatio: \`\${width} / \${height}\`, borderRadius: radius, backgroundColor: color }}
    >
      {lqip ? (
        <motion.img
          src={lqip}
          alt=""
          aria-hidden
          draggable={false}
          className="absolute inset-0 size-full object-cover will-change-[opacity,transform]"
          style={{ filter: \`blur(\${blur}px)\` }}
          initial={false}
          animate={ready ? { opacity: 0, scale: 1.015 } : { opacity: 1, scale: 1.08 }}
          transition={reducedMotion ? { duration: 0 } : REVEAL}
        />
      ) : null}

      {src ? (
        <motion.div
          className="absolute inset-0 will-change-[opacity,transform]"
          initial={false}
          animate={ready ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 1.02 }}
          transition={reducedMotion ? { duration: 0 } : REVEAL}
        >
          <Image
            {...imageProps}
            src={src}
            alt={alt}
            width={width}
            height={height}
            sizes={imageProps.sizes ?? "100vw"}
            onLoad={reveal}
            onError={() => { setStatus("error"); onError?.(); }}
            className={\`size-full object-cover \${imageClassName ?? ""}\`}
            style={{ width: "100%", height: "100%", objectFit: "cover" }}
          />
        </motion.div>
      ) : null}

      {failed ? (
        <motion.div
          role="status"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={reducedMotion ? { duration: 0 } : { duration: 0.22 }}
          className="absolute inset-0 grid place-items-center bg-background/90 text-center text-xs text-muted-foreground backdrop-blur-sm"
        >
          Image failed to load
        </motion.div>
      ) : null}
    </div>
  );
}

Usage

project-card.tsx
// Keep a local JPG, PNG, WebP, or AVIF beside this file.
import projectPreview from "./project-preview.jpeg";
import { BlurUpImage } from "@/components/interior/blur-up-image";

export function ProjectCard() {
  return (
    <BlurUpImage
      src={projectPreview}
      alt="Project preview"
      width={projectPreview.width}
      height={projectPreview.height}
      color="#18231c"
      sizes="(min-width: 768px) 420px, 100vw"
      quality={75}
      loading="lazy"
      radius={12}
    />
  );
}

Props

src
ImageProps["src"] | undefined

The local, remote, or static image source Next.js will optimise. Undefined keeps the placeholder stable while a URL resolves.

alt
string

Required description for the final image. It remains on the optimised image throughout its lifecycle.

width
number

Intrinsic width used with height to reserve space and eliminate layout shift.

height
number

Intrinsic height used with width to reserve space and eliminate layout shift.

placeholder
string

A tiny LQIP data URI rendered immediately. Omit it for a static import and the component uses Next.js's generated blurDataURL.

colorundefined
string

Dominant colour painted before the LQIP has decoded.

blur18
number

Static LQIP blur radius in pixels. It does not animate or repaint every frame.

radius12
number

Frame corner radius, applied inline so every caller can choose its own value.

sizes"100vw"
string

Responsive display size passed to next/image. Set this accurately to prevent oversized downloads.

qualityNext default
number

Output quality for Next.js optimisation. 75 is a strong general-purpose value.

loading"lazy"
"lazy" | "eager"

Native loading strategy passed to next/image. Use eager only for an above-the-fold image.

fetchPriorityundefined
"high" | "low" | "auto"

Use high for the one LCP candidate instead of marking many images high priority.

preloadfalse
boolean

Adds a preload hint. Use it only for a confirmed LCP image; do not combine it with loading or fetchPriority.

onReadyundefined
() => void

Fires after the optimised image has decoded and is ready to reveal.

onErrorundefined
() => void

Fires when the image request or decode fails.

imageClassNameundefined
string

Classes appended to the underlying next/image element.