All patterns

Progressive Image Generation

A generated picture resolves from blur to sharp across a few discrete refinement passes.

aifuturisticpremiumautomatic · finite · intermediate · ~2.2s
Variant

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.

298 lines · react + motion only
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.

Image generation progressAsset renderingUpscale previewModel output arriving

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Ask a follow-up
    AI assistant

    Results appear coarse and are replaced by progressively sharper versions as passes finish.

Related patterns