Image Blur Up
A tiny blurred stand-in holds the frame and sharpens into the full picture, with nothing below it moving.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Image Blur Up
*
* A cheap placeholder — a handful of bytes of gradient, or the tiny
* base64 thumbnail your image pipeline already emits — sits in the
* frame at full blur and sharpens as the real file arrives. The frame
* is sized by aspect ratio from the first paint, so nothing below it
* moves when the image lands.
*
* Self-contained: depends only on `motion` (react ships with your app).
* With no `src` the frame renders an inline SVG stand-in, so the file
* runs with zero props and no assets. Pass `src` for the real thing.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ImageBlurUpProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Full-resolution image. Omitted, an inline SVG stands in for it. */
src?: string;
/** Alternative text for the full image. */
alt?: string;
/** Any CSS background value: a tiny base64 thumbnail, a dominant-color
* gradient, whatever your pipeline produces. */
placeholder?: string;
/** Drive this from your own load state. Left undefined, the component
* uses the image's own load event, or a timer when there is no src. */
loaded?: boolean;
/** Stand-in decode time used only when there is no src, in ms. */
revealAfterMs?: number;
/** Frame width — px number or any CSS length. */
width?: number | string;
/** Frame aspect ratio, width ÷ height. */
aspectRatio?: number;
/** Caption under the frame. */
caption?: string;
/** Secondary line under the caption. */
meta?: string;
/** Fires once the full image is showing. */
onLoaded?: () => void;
};
type VariantConfig = {
/** Starting blur radius on the placeholder, in px. */
blur: number;
/** Placeholder overscale — hides the soft edge a blur leaves behind. */
overscale: number;
/** How much the sharp layer settles in by, as a scale factor. */
settleFrom: number;
sharpenSeconds: number;
fadeSeconds: number;
captionDelay: number;
};
// Quality rule: only the picture scales. The caption underneath fades
// on a fixed baseline and at a constant size, because type that grows
// into place reads as a zoom, not as content arriving. Variants differ
// in blur depth and pace — none of them bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a haze. For thumbnail grids where a dozen of these resolve
// within a second of each other.
subtle: {
blur: 10,
overscale: 1.03,
settleFrom: 1,
sharpenSeconds: 0.32,
fadeSeconds: 0.26,
captionDelay: 0.06,
},
// The all-purpose setting: a clear before and after, over in a third
// of a second.
default: {
blur: 18,
overscale: 1.06,
settleFrom: 1.01,
sharpenSeconds: 0.46,
fadeSeconds: 0.34,
captionDelay: 0.1,
},
// Deeper blur and a longer sharpen, for a single hero image that owns
// the top of the page.
playful: {
blur: 26,
overscale: 1.09,
settleFrom: 1.03,
sharpenSeconds: 0.62,
fadeSeconds: 0.42,
captionDelay: 0.14,
},
};
/** Theme-adaptive neutral for the card chrome. The picture's own colors
* stay literal — they stand in for photography, not for a surface. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** The low-quality stand-in: three colors and a soft falloff, which is
* all a downsampled thumbnail amounts to once it has been blurred. */
const DEFAULT_PLACEHOLDER =
"radial-gradient(62% 72% at 70% 64%, #F3B172 0%, #C4665A 40%, rgba(196,102,90,0) 74%), " +
"linear-gradient(158deg, #1B2A6B 0%, #4A4DA6 54%, #2A2350 100%)";
export default function ImageBlurUp({
variant = "default",
src,
alt = "Cover image",
placeholder = DEFAULT_PLACEHOLDER,
loaded,
revealAfterMs = 1100,
width = 300,
aspectRatio = 8 / 5,
caption = "Annual review — cover",
meta = "2400 × 1500 · JPEG",
onLoaded,
}: ImageBlurUpProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfLoaded, setSelfLoaded] = useState(false);
const gradientId = useId();
// Uncontrolled and src-less, the file still has to demonstrate itself,
// so a timer stands in for the network. Either real prop takes over.
useEffect(() => {
if (loaded !== undefined || src) return;
const timer = setTimeout(() => setSelfLoaded(true), revealAfterMs);
return () => clearTimeout(timer);
}, [loaded, src, revealAfterMs]);
const isLoaded = loaded ?? selfLoaded;
const sharpen = { duration: cfg.sharpenSeconds, ease: [0.22, 0.61, 0.36, 1] as const };
// Reduced motion keeps both layers and the order they arrive in, and
// drops the two things that actually move: the blur radius resolving
// and the frame settling. The placeholder simply holds its blur and
// cross-fades out under the sharp image.
const blurPx = isLoaded && !reduceMotion ? 0 : cfg.blur;
const placeholderScale = isLoaded && !reduceMotion ? 1 : cfg.overscale;
return (
<div style={{ width, display: "flex", flexDirection: "column", gap: 10 }}>
{/* The frame is sized by aspect ratio before a single byte of image
has arrived. Everything below it is already in its final place,
which is the difference between a blur-up and a layout shift
with a blur on top. */}
<div
aria-busy={!isLoaded}
style={{
position: "relative",
width: "100%",
aspectRatio: String(aspectRatio),
borderRadius: 14,
overflow: "hidden",
background: tone(8),
border: `1px solid ${tone(10)}`,
}}
>
<motion.div
aria-hidden
initial={false}
animate={{
filter: `blur(${blurPx}px)`,
scale: placeholderScale,
opacity: isLoaded ? 0 : 1,
}}
transition={{
filter: sharpen,
scale: sharpen,
opacity: {
duration: cfg.fadeSeconds,
ease: "easeOut",
// The placeholder leaves a beat after the sharp layer has
// started arriving, so the frame is never empty mid-swap.
delay: isLoaded && !reduceMotion ? cfg.sharpenSeconds * 0.35 : 0,
},
}}
style={{
position: "absolute",
inset: 0,
background: placeholder,
// Overscaling under a blur keeps the soft edge outside the
// frame; without it the corners look chewed.
transformOrigin: "center",
}}
/>
<motion.div
initial={false}
animate={{
opacity: isLoaded ? 1 : 0,
scale: isLoaded || reduceMotion ? 1 : cfg.settleFrom,
}}
transition={{
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
scale: sharpen,
}}
style={{ position: "absolute", inset: 0 }}
onAnimationComplete={() => {
if (isLoaded) onLoaded?.();
}}
>
{src ? (
<img
src={src}
alt={alt}
onLoad={() => setSelfLoaded(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<SampleImage gradientId={gradientId} />
)}
</motion.div>
</div>
{/* Caption travels nowhere and never changes size — it only fades
up to full strength once the picture it describes is there. */}
<motion.div
initial={false}
animate={{ opacity: isLoaded ? 1 : 0.38 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: isLoaded && !reduceMotion ? cfg.captionDelay : 0,
}}
>
<div style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.3 }}>
{caption}
</div>
<div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 3 }}>{meta}</div>
</motion.div>
</div>
);
}
/** Stand-in for a photograph so the file needs no asset and no network.
* Its colors are literal on purpose: this is a picture, not a surface,
* so it must not adapt to the page theme. */
function SampleImage({ gradientId }: { gradientId: string }) {
const sky = `${gradientId}-sky`;
const glow = `${gradientId}-glow`;
return (
<svg
viewBox="0 0 320 200"
preserveAspectRatio="xMidYMid slice"
style={{ width: "100%", height: "100%", display: "block" }}
role="img"
aria-label="Abstract cover image"
>
<defs>
<linearGradient id={sky} x1="0" y1="0" x2="0.7" y2="1">
<stop offset="0%" stopColor="#16235C" />
<stop offset="46%" stopColor="#4A4DA6" />
<stop offset="78%" stopColor="#B65F5C" />
<stop offset="100%" stopColor="#F0A868" />
</linearGradient>
<radialGradient id={glow} cx="0.72" cy="0.62" r="0.42">
<stop offset="0%" stopColor="#FFD9A0" stopOpacity="0.95" />
<stop offset="100%" stopColor="#FFD9A0" stopOpacity="0" />
</radialGradient>
</defs>
<rect width="320" height="200" fill={`url(#${sky})`} />
<rect width="320" height="200" fill={`url(#${glow})`} />
<circle cx="230" cy="124" r="22" fill="#FFE3B8" opacity="0.9" />
<path d="M0 152 L86 116 L164 150 L246 108 L320 138 V200 H0 Z" fill="#241C4A" opacity="0.72" />
<path d="M0 176 L72 148 L150 178 L232 146 L320 172 V200 H0 Z" fill="#150F30" opacity="0.85" />
</svg>
);
}About this pattern
What a good image pipeline looks like from the outside. A few bytes of gradient or a downsampled thumbnail fill the frame immediately at heavy blur; when the full file decodes, the blur resolves to zero, the overscale settles back to one, and the stand-in cross-fades away underneath. The frame is sized by aspect ratio from the first paint, so the caption and everything beneath it are already in their final position — this is what separates a blur-up from a layout shift with a blur painted over it. The placeholder is overscaled slightly while blurred, because a blur radius bleeds past its own edges and un-overscaled corners look chewed.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Photo gallery
Grid tiles hold a dominant-color block that gives way to the photograph, with no reflow.
Related patterns
- Page Transition FadeThe outgoing view sinks a few pixels as it fades while the incoming one rises to meet it.
- Carousel Preload SlideThe upcoming frame resolves from placeholder to artwork a beat before the track moves to it.
- Cache Hit InstantCached content gets no entrance at all; only the values that actually changed animate.
