Progressive Image Generation
A generated picture resolves from blur to sharp across a few discrete refinement passes.
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, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Progressive Image Generation
*
* A generated image arriving the way it is actually produced: in a few
* discrete refinement passes, each one holding long enough to be seen as
* a step rather than a smooth ramp.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Chrome is mixed from the inherited text color; the image stand-in
* keeps literal colors, because it represents a picture, not a surface.
* Works with zero props; tune via `variant`, `passes`, `passMs`, `prompt`,
* `imageSrc`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ImageGenerationProgressiveProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many refinement passes are shown. */
passes?: number;
/** ms each pass holds before the next one starts. */
passMs?: number;
/** Caption under the frame. */
prompt?: string;
/** The finished picture; omit for the painted stand-in. */
imageSrc?: string;
/** Accent for the pass meter. */
color?: string;
};
type VariantConfig = {
/** Blur in px on the first pass. */
startBlur: number;
/** Scale the frame contents start at, settling to 1. */
startScale: number;
/** Saturation on the first pass, ramping to 1. */
startSaturation: number;
/** Seconds each pass takes to resolve, before it holds. */
resolveSeconds: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short throw — the picture is nearly there from the first pass.
subtle: { startBlur: 10, startScale: 1.02, startSaturation: 0.85, resolveSeconds: 0.28 },
// The all-purpose setting.
default: { startBlur: 18, startScale: 1.04, startSaturation: 0.72, resolveSeconds: 0.34 },
// A long throw from near-abstract, for a hero generation surface.
playful: { startBlur: 26, startScale: 1.06, startSaturation: 0.6, resolveSeconds: 0.4 },
};
const DONE = "#34D399";
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* that is correctly toned in either theme. The image stand-in below is
* deliberately literal: it stands in for a photograph. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ImageGenerationProgressive({
variant = "default",
passes = 4,
passMs = 600,
prompt = "Studio product shot of a ceramic mug on a linen backdrop",
imageSrc,
color = "#7C7CF0",
}: ImageGenerationProgressiveProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [pass, setPass] = useState(0);
const last = Math.max(1, passes - 1);
const complete = pass >= last;
// One timer per pass, scheduled from the pass currently on screen:
// the sequence advances by rescheduling itself, so it cannot drift and
// unmounting mid-render leaves nothing pending.
useEffect(() => {
if (pass >= last) return;
const timer = setTimeout(() => setPass((value) => value + 1), passMs);
return () => clearTimeout(timer);
}, [pass, last, passMs]);
// Each pass is a discrete target the image eases to and then holds —
// that hold is what makes it read as a pass rather than a slow ramp.
const remaining = 1 - pass / last;
const blur = reduceMotion ? 0 : cfg.startBlur * remaining * remaining;
const scale = reduceMotion ? 1 : 1 + (cfg.startScale - 1) * remaining;
const saturation = reduceMotion
? 1
: cfg.startSaturation + (1 - cfg.startSaturation) * (1 - remaining);
return (
<div style={{ width: 268, display: "grid", gap: 11 }}>
<div
style={{
position: "relative",
height: 178,
borderRadius: 14,
overflow: "hidden",
border: `1px solid ${tone(12)}`,
background: tone(8),
}}
>
<motion.div
aria-hidden
// Reduced motion: no blur ramp and no scale — the finished
// picture is simply there, and the pass counter below still
// reports what the model is doing.
animate={{
filter: `blur(${blur}px) saturate(${saturation})`,
scale,
}}
transition={{
duration: reduceMotion ? 0 : cfg.resolveSeconds,
ease: [0.22, 1, 0.36, 1],
}}
style={{
position: "absolute",
// Overscanned so the blur never exposes the frame edge.
inset: -14,
background:
"linear-gradient(155deg, #2B2F4A 0%, #6B5B9A 42%, #C97F6B 74%, #E8B98D 100%)",
}}
>
{imageSrc ? (
<img
src={imageSrc}
alt=""
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<>
<div
style={{
position: "absolute",
left: "16%",
top: "26%",
width: 96,
height: 96,
borderRadius: "50%",
background:
"radial-gradient(circle at 34% 30%, rgba(255,244,230,0.92), rgba(255,214,176,0.16) 62%, transparent 72%)",
}}
/>
<div
style={{
position: "absolute",
right: "10%",
bottom: "-14%",
width: 150,
height: 110,
borderRadius: "46% 46% 12% 12%",
background:
"linear-gradient(180deg, rgba(20,18,36,0.55), rgba(20,18,36,0.05))",
}}
/>
</>
)}
</motion.div>
{/* A thin veil that clears with the last pass, so the finish has
a moment of its own rather than just being "less blurred". */}
<motion.div
aria-hidden
animate={{ opacity: complete ? 0 : 0.24 }}
transition={{ duration: reduceMotion ? 0 : 0.4, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(180deg, rgba(10,10,20,0.5), rgba(10,10,20,0.1))",
}}
/>
<AnimatePresence>
{complete && (
<motion.span
key="done"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
left: 10,
bottom: 10,
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "4px 9px 4px 7px",
borderRadius: 999,
background: "rgba(12,12,20,0.55)",
color: "#fff",
fontSize: 11,
fontWeight: 600,
backdropFilter: "blur(6px)",
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path
d="M2.8 6.2 4.9 8.3 9.2 3.9"
stroke={DONE}
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Rendered
</motion.span>
)}
</AnimatePresence>
</div>
<div style={{ display: "grid", gap: 7 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
fontSize: 11.5,
minHeight: 15,
}}
>
{/* The label crossfades in place; the digits are tabular so the
counter never shifts the row as it counts. */}
<AnimatePresence initial={false} mode="wait">
<motion.span
key={complete ? "complete" : pass}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ opacity: 0.6, fontVariantNumeric: "tabular-nums" }}
>
{complete ? "Refinement complete" : `Pass ${pass + 1} of ${passes}`}
</motion.span>
</AnimatePresence>
<span style={{ opacity: 0.38, fontVariantNumeric: "tabular-nums" }}>
1024 x 1024
</span>
</div>
<div style={{ display: "flex", gap: 4 }}>
{Array.from({ length: passes }, (_, index) => (
<span
key={index}
style={{
flex: 1,
height: 3,
borderRadius: 999,
background: tone(12),
overflow: "hidden",
}}
>
<motion.span
initial={false}
animate={
reduceMotion
? { opacity: index <= pass ? 1 : 0, scaleX: 1 }
: { scaleX: index <= pass ? 1 : 0, opacity: 1 }
}
transition={{
duration: reduceMotion ? 0 : 0.3,
ease: [0.22, 1, 0.36, 1],
}}
style={{
display: "block",
height: "100%",
borderRadius: 999,
background: complete ? DONE : color,
// scaleX, not width: transforms do not touch layout.
transformOrigin: "left center",
}}
/>
</span>
))}
</div>
<span style={{ fontSize: 11.5, lineHeight: 1.45, opacity: 0.42 }}>
{prompt}
</span>
</div>
</div>
);
}About this pattern
Image generation is slow and, until the last second, invisible — so the wait is spent showing the work. Each pass eases to its target sharpness and then holds, and the hold is the whole trick: a continuous ramp reads as a loading bar, while a stepped one reads as a model committing to detail it did not have before. Saturation and a small settle in scale ride along, a veil clears on the final pass so the finish gets a moment, and the pass meter fills with scaleX rather than width.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Results appear coarse and are replaced by progressively sharper versions as passes finish.
Related patterns
- Streaming Code BlockGenerated code arrives line by line while the panel grows downward to fit it.
- Multi-Agent HandoffA pip crosses the connector, the receiving card lifts as it takes the work, and the finished one recedes with a drawn check.
- AI Result RevealThe result card rises into place while its confidence value counts up to the final number.