Next.js Blur-up Image
A decode-aware reveal built on real Next.js image optimisation.
Installation
pnpm add motionSource
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"] | undefinedThe local, remote, or static image source Next.js will optimise. Undefined keeps the placeholder stable while a URL resolves.
- alt
stringRequired description for the final image. It remains on the optimised image throughout its lifecycle.
- width
numberIntrinsic width used with height to reserve space and eliminate layout shift.
- height
numberIntrinsic height used with width to reserve space and eliminate layout shift.
- placeholder
stringA tiny LQIP data URI rendered immediately. Omit it for a static import and the component uses Next.js's generated blurDataURL.
- colorundefined
stringDominant colour painted before the LQIP has decoded.
- blur18
numberStatic LQIP blur radius in pixels. It does not animate or repaint every frame.
- radius12
numberFrame corner radius, applied inline so every caller can choose its own value.
- sizes"100vw"
stringResponsive display size passed to next/image. Set this accurately to prevent oversized downloads.
- qualityNext default
numberOutput 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
booleanAdds a preload hint. Use it only for a confirmed LCP image; do not combine it with loading or fetchPriority.
- onReadyundefined
() => voidFires after the optimised image has decoded and is ready to reveal.
- onErrorundefined
() => voidFires when the image request or decode fails.
- imageClassNameundefined
stringClasses appended to the underlying next/image element.