Holds its space, then brings the full image into focus over a blurred preview.
Mediano dependencies
01Preview
Hiking trip, day 3
Shared by Maya Chen · 3 photos
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup.
03Usage
import { BlurUpImage } from "@/components/ui/blur-up-image";
<BlurUpImage
src="/photos/quiraing.jpg"
placeholder="/photos/quiraing-24.jpg"
alt="Morning light over a green valley"
width={1600}
height={1000}
className="rounded-xl"
/>
// No preview file? The dominant color is enough to hold the space.
<BlurUpImage src={photo.url} color={photo.color} alt={photo.alt} aspectRatio={1} />04Source
"use client";
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
export type ImageStatus = "loading" | "loaded" | "error";
const noop = () => () => {};
/**
* The load lifecycle of one image, without the markup. Spread `imgProps` on an <img>.
* `instant` is true when the image was already decoded the moment it mounted on the
* client (the browser cache), so a caller can skip its reveal instead of replaying it.
* Give the <img> the returned `key` so a retry or a new src gets a fresh element.
*/
export function useImageLoad(src: string | undefined, { onLoad, onError }: { onLoad?: () => void; onError?: () => void } = {}) {
const [attempt, setAttempt] = useState(0);
const key = `${src ?? ""}#${attempt}`;
const [result, setResult] = useState<{ key: string; status: ImageStatus; instant: boolean } | null>(null);
// During hydration the server snapshot (false) is used, so an image that finished before
// React arrived still gets its reveal: the visitor was looking at the placeholder until now.
const hydrated = useSyncExternalStore(noop, () => true, () => false);
const settledKey = useRef<string>(null);
const current = result?.key === key ? result : null;
const status: ImageStatus = src === undefined ? "loading" : (current?.status ?? "loading");
const settle = useCallback(
(img: HTMLImageElement, instant: boolean) => {
if (settledKey.current === key) return;
settledKey.current = key;
// Wait for decode so the reveal never starts on a half-painted frame.
const done = () => {
setResult({ key, status: "loaded", instant });
onLoad?.();
};
if (instant || !img.decode) done();
else img.decode().then(done, done);
},
[key, onLoad],
);
// Catches images that finished before React attached its listeners (cache, or before hydration).
const ref = useCallback(
(img: HTMLImageElement | null) => {
if (!img || !src || !img.complete) return;
if (img.naturalWidth > 0) settle(img, hydrated);
else if (img.currentSrc) setResult({ key, status: "error", instant: false });
},
[key, src, settle, hydrated],
);
return {
status,
instant: !!current?.instant,
attempt,
retry: () => setAttempt((a) => a + 1),
key,
imgProps: {
ref,
onLoad: (e: React.SyntheticEvent<HTMLImageElement>) => settle(e.currentTarget, false),
onError: () => {
settledKey.current = null;
setResult({ key, status: "error", instant: false });
onError?.();
},
},
};
}
export type BlurUpImageProps = Omit<React.ComponentProps<"div">, "children" | "onLoad" | "onError"> & {
/** Leave undefined while the URL is still being resolved; the placeholder holds the space. */
src?: string;
alt: string;
srcSet?: string;
sizes?: string;
/** Intrinsic size. Either this pair or `aspectRatio` reserves the box before any bytes arrive. */
width?: number;
height?: number;
aspectRatio?: number | string;
/** A tiny version of the image (a 16–32px URL or a data URI), shown blurred until the real one lands. */
placeholder?: string;
/** The image's dominant color, painted behind everything. Any CSS color. */
color?: string;
fit?: "cover" | "contain";
/** Load eagerly at high priority. Use for the one image above the fold that matters. */
priority?: boolean;
/** Replaces the default failed state. */
fallback?: React.ReactNode;
errorLabel?: string;
retryLabel?: string;
onLoad?: () => void;
onError?: () => void;
/** Called when the person presses the retry button, before the image is requested again. */
onRetry?: () => void;
imgClassName?: string;
};
export function BlurUpImage({
src,
alt,
srcSet,
sizes,
width,
height,
aspectRatio,
placeholder,
color,
fit = "cover",
priority = false,
fallback,
errorLabel = "Couldn’t load image",
retryLabel = "Try again",
onLoad,
onError,
onRetry,
className,
imgClassName,
style,
ref,
...rest
}: BlurUpImageProps) {
const { status, instant, key, imgProps, retry, attempt } = useImageLoad(src, { onLoad, onError });
const rootRef = useRef<HTMLDivElement>(null);
const ratio = aspectRatio ?? (width && height ? `${width} / ${height}` : undefined);
const settled = status === "loaded";
return (
<div
ref={(node) => {
rootRef.current = node;
if (typeof ref === "function") return ref(node);
if (ref) ref.current = node;
}}
// Focus lands here when the retry button it held disappears, instead of dropping to the page.
tabIndex={-1}
data-state={status}
data-fit={fit}
aria-busy={status === "loading" || undefined}
className={cn("@container relative isolate overflow-hidden bg-hover outline-none", className)}
style={{ aspectRatio: ratio, backgroundColor: color, ...style }}
{...rest}
>
{/* Nothing to show yet and nothing known about the image: a slow, low pulse, only after 400ms. */}
{!placeholder && !color && (
<span
aria-hidden
className={cn(
"absolute inset-0 bg-fg/[0.025] transition-opacity duration-300",
status === "loading" ? "animate-pulse-soft [animation-delay:400ms] motion-reduce:animate-none" : "opacity-0",
)}
/>
)}
{placeholder && (
// Scaled past the edges so the blur has pixels to pull from and never shows a soft border.
// eslint-disable-next-line @next/next/no-img-element
<img
aria-hidden
alt=""
src={placeholder}
decoding="async"
className={cn(
"absolute inset-0 size-full scale-110 object-cover blur-xl",
"transition-opacity duration-300 ease-out",
// Leaves only once the real image is fully in, so the two never show a gap between them.
settled && !instant ? "opacity-0 delay-500" : settled ? "opacity-0 duration-0" : "opacity-100",
)}
/>
)}
{src !== undefined && (
// eslint-disable-next-line @next/next/no-img-element
<img
key={key}
{...imgProps}
src={src}
srcSet={srcSet}
sizes={sizes}
alt={alt}
width={width}
height={height}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : undefined}
decoding="async"
draggable={false}
data-instant={instant || undefined}
className={cn(
"absolute inset-0 size-full",
fit === "cover" ? "object-cover" : "object-contain",
// The reveal: fades in while it sharpens and settles, so it reads as the placeholder coming into focus.
"transition-[opacity,filter,scale] ease-out-quart motion-reduce:transition-opacity",
"[transition-duration:450ms,650ms,700ms] motion-reduce:duration-200",
settled ? "scale-100 opacity-100 blur-[0px]" : "scale-[1.03] opacity-0 blur-md motion-reduce:scale-100 motion-reduce:blur-none",
"data-instant:transition-none",
imgClassName,
)}
/>
)}
{status === "error" &&
(fallback ?? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-[inherit] border border-line bg-raised p-3 text-center transition-opacity duration-200 ease-out starting:opacity-0">
<BrokenImage className="text-fg-4" />
<p className="hidden text-[12px] leading-4 text-fg-3 @[140px]:block">{errorLabel}</p>
<button
type="button"
onClick={(e) => {
if (e.currentTarget === document.activeElement) rootRef.current?.focus({ preventScroll: true });
onRetry?.();
retry();
}}
aria-label={`${retryLabel}: ${alt}`}
className={cn(
"group/retry relative inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-raised px-2 text-[12px] font-medium text-fg shadow-[var(--shadow)]",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.96] active:duration-75",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
)}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
// Each attempt turns the arrow once more, so a second press visibly does something.
style={{ rotate: `${attempt * 360}deg` }}
className="text-fg-2 transition-[rotate] duration-500 ease-out-expo motion-reduce:transition-none"
>
<path d="M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25" />
</svg>
<span className="hidden @[110px]:inline">{retryLabel}</span>
</button>
</div>
))}
</div>
);
}
function BrokenImage({ className }: { className?: string }) {
return (
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden className={className}>
<path d="M13.5 9.5V4.75A1.75 1.75 0 0 0 11.75 3h-6.5M2.5 5.25v6A1.75 1.75 0 0 0 4.25 13h7" />
<path d="m2.75 11.25 3.4-3 2.4 2M2.5 2.5l11 11" />
<circle cx="10" cy="6.25" r="1" />
</svg>
);
}05Props
BlurUpImage
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | — | The full image. Leave undefined while the URL is still resolving; the placeholder holds the space. |
| alt* | string | — | What the image shows. Use an empty string only for decoration. |
| width / height | number | — | Intrinsic size, used to reserve the box before any bytes arrive. |
| aspectRatio | number | string | — | Reserve the box by ratio instead, e.g. 1 or "4 / 3". |
| placeholder | string | — | A 16–32px version of the image (URL or data URI), shown blurred until the real one lands. |
| color | string | — | The image's dominant color, painted behind everything. Any CSS color. |
| fit | "cover" | "contain" | "cover" | How the image fills the box. |
| priority | boolean | false | Load eagerly at high fetch priority, for the one image above the fold that matters. |
| srcSet / sizes | string | — | Passed to the img for responsive sources. |
| fallback | ReactNode | — | Replaces the built-in failed state. |
| errorLabel | string | "Couldn’t load image" | Shown when the image fails, if the box is at least 140px wide. |
| retryLabel | string | "Try again" | Label of the retry button. Its accessible name also includes the alt text. |
| onLoad | () => void | — | Called once the image has loaded and decoded. |
| onError | () => void | — | Called when the image fails to load or decode. |
| onRetry | () => void | — | Called when retry is pressed, before the image is requested again. Swap to a mirror here if you have one. |
| imgClassName | string | — | Classes for the full-size img element. className styles the frame. |
useImageLoad
| Prop | Type | Default | Description |
|---|---|---|---|
| src* | string | undefined | — | The image to track. Returns { status, instant, key, imgProps, retry, attempt }; give the img the key and spread imgProps. |
| onLoad / onError | () => void | — | Called when the image settles either way. |
06Notes
Behavior
- The box is sized before the first byte, from width and height or aspectRatio, so nothing below it moves when the image arrives.
- The reveal waits for decode(), so the crossfade never starts on a half-painted frame. An image already in the cache on a client mount appears at once instead of replaying the reveal; one that finished before hydration still reveals, because the visitor was looking at the placeholder.
- Failure shows a neutral panel at the same size with a retry button, never a broken-image icon. Retrying requests a fresh element, and keyboard focus moves to the frame instead of dropping to the page when the button goes away.
- With neither placeholder nor color, the empty frame pulses slowly, and only after 400ms, so fast loads never flash a loading state.
Motion
- The full image fades in over 450ms while its 12px blur clears over 650ms and it settles from scale 1.03 over 700ms, all on the quart ease-out: the preview seems to come into focus rather than be replaced.
- The blurred preview stays underneath until the reveal is done, then fades in 300ms after a 500ms delay, so the two never show a gap.
- The retry arrow turns a full revolution on each attempt (500ms, expo ease-out), so pressing it twice still visibly does something.
- Reduced motion keeps a 200ms opacity fade and drops the blur, scale and turn.
Accessibility
- A real img with its alt; the blurred preview is aria-hidden with an empty alt, so the image is announced once.
- The frame carries aria-busy while loading.
- The retry button's accessible name includes the alt ("Try again: Mountain tops above a sea of cloud"), so a grid of failed images stays distinguishable.