A prompt card. Generate runs Composing, Denoising and Upscaling while the model takes a random 4 to 9 seconds, so the bar is often deep in the ruler when the image arrives; the gap snaps shut and the image resolves in. Generate again for another take.
registry/demos/zeno-progress-demo.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ZenoProgress } from "@/components/ui/zeno-progress";
const prompts = [
{
text: "Alpine ridge at first light, low cloud pooled in the valley, 35mm film",
image: "1464822759023-fed622ff2c3b",
},
{
text: "Still lake under a pine slope, late summer, soft overcast light",
image: "1501785888041-af3ef285b470",
},
{
text: "Snowy peaks under a clear night sky, long exposure, cold blue tones",
image: "1519681393784-d120267933ba",
},
];
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=960&h=600&q=80`;
type Phase = "idle" | "running" | "finishing" | "shown";
export function ZenoProgressDemo() {
const [phase, setPhase] = React.useState<Phase>("idle");
const [take, setTake] = React.useState(0);
const prompt = prompts[take % prompts.length];
// The "model" answers after 4–9 s; the bar only finishes when it does.
React.useEffect(() => {
if (phase !== "running") return;
const timer = window.setTimeout(() => setPhase("finishing"), 4000 + Math.random() * 5000);
return () => window.clearTimeout(timer);
}, [phase, take]);
// Load the next image while the bar runs, so it's ready for the reveal.
React.useEffect(() => {
const next = new Image();
next.src = photo(prompt.image);
}, [prompt.image]);
const generate = () => {
if (phase === "shown") setTake((t) => t + 1);
setPhase("running");
};
const busy = phase === "running" || phase === "finishing";
return (
<div className="w-full max-w-md rounded-2xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-800 dark:bg-zinc-950">
<div className="relative aspect-[8/5] overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-900">
<AnimatePresence initial={false}>
{phase === "shown" ? (
<motion.img
key={prompt.image}
src={photo(prompt.image)}
alt={prompt.text}
className="absolute inset-0 size-full object-cover"
initial={{ opacity: 0, scale: 1.06, filter: "blur(18px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, transition: { duration: 0.25 } }}
transition={{ type: "spring", visualDuration: 0.7, bounce: 0 }}
/>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{phase !== "shown" ? (
<motion.div
key="empty"
className="absolute inset-0 flex items-end p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
<div className="w-full">
{busy ? (
<ZenoProgress
key={take}
done={phase === "finishing"}
steps={["Composing", "Denoising", "Upscaling"]}
doneLabel="Ready"
interval={900}
onComplete={() => window.setTimeout(() => setPhase("shown"), 220)}
/>
) : (
<p className="text-sm text-zinc-500 dark:text-zinc-400">Your image appears here.</p>
)}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div className="mt-3 flex items-end gap-3 px-1 pb-1">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-zinc-400 dark:text-zinc-500">Prompt</p>
<p className="mt-1 text-sm leading-snug text-zinc-800 dark:text-zinc-200">{prompt.text}</p>
</div>
<button
type="button"
onClick={generate}
disabled={busy}
className="shrink-0 rounded-full bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-[opacity,transform] active:scale-[0.97] disabled:opacity-40 dark:bg-zinc-100 dark:text-zinc-900"
>
{phase === "shown" ? "Generate again" : "Generate"}
</button>
</div>
</div>
);
}
Live preview, examples and props: https://keyframeui.com/components/zeno-progress#zeno-progress-demo