Image Generation
A stable generated-image surface that moves from queued work through progressive refinement to a completed result without layout shift.
Preview
“a quiet mountain landscape at sunset”
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import {
ImageGeneration,
type ImageGenerationStatus,
} from "@/components/agents/image-generation";
function GeneratedArtwork() {
return (
<svg
viewBox="0 0 800 600"
aria-hidden="true"
className="size-full"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<linearGradient id="image-sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#191b33" />
<stop offset="0.48" stopColor="#60538d" />
<stop offset="1" stopColor="#e59b7b" />
</linearGradient>
<linearGradient id="image-ground" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#314946" />
<stop offset="1" stopColor="#101817" />
</linearGradient>
<radialGradient id="image-glow">
<stop offset="0" stopColor="#ffe6b2" stopOpacity="0.95" />
<stop offset="1" stopColor="#efb47e" stopOpacity="0" />
</radialGradient>
</defs>
<rect width="800" height="600" fill="url(#image-sky)" />
<circle cx="570" cy="210" r="150" fill="url(#image-glow)" />
<circle cx="570" cy="210" r="54" fill="#ffe5ad" />
<path
d="M0 395 120 314l92 54 132-143 116 126 78-66 92 88 170-61v288H0Z"
fill="#283d43"
opacity="0.9"
/>
<path
d="M0 432 118 376l99 73 115-97 107 92 112-52 108 68 141-48v188H0Z"
fill="url(#image-ground)"
/>
<path
d="M0 505c116-44 190-32 280 6 103 44 212 47 330-7 68-31 126-37 190-28v124H0Z"
fill="#13201f"
/>
<g fill="#c9d7c3" opacity="0.65">
<circle cx="108" cy="142" r="2" />
<circle cx="168" cy="102" r="1.5" />
<circle cx="248" cy="154" r="2" />
<circle cx="332" cy="84" r="1.5" />
<circle cx="414" cy="138" r="2" />
</g>
</svg>
);
}
function GenerationDemo({ onReplay }: { onReplay: () => void }) {
const reduce = useReducedMotion() ?? false;
const [status, setStatus] = useState<ImageGenerationStatus>(
reduce ? "complete" : "queued",
);
useEffect(() => {
if (reduce) return;
const timers = [
window.setTimeout(() => setStatus("generating"), 500),
window.setTimeout(() => setStatus("refining"), 3000),
window.setTimeout(() => setStatus("complete"), 5200),
];
return () => {
timers.forEach((timer) => {
window.clearTimeout(timer);
});
};
}, [reduce]);
return (
<div className="flex w-full flex-col items-center">
<ImageGeneration
label="A quiet mountain landscape at sunset"
prompt="a quiet mountain landscape at sunset"
resolution="1024 × 1024"
status={status}
onRetry={onReplay}
>
<GeneratedArtwork />
</ImageGeneration>
<button
type="button"
onClick={onReplay}
className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-full px-3 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw aria-hidden="true" className="size-4" />
Replay
</button>
</div>
);
}
export function ImageGenerationPreview() {
const [run, setRun] = useState(0);
return (
<div className="w-full max-w-xl">
<GenerationDemo
key={run}
onReplay={() => setRun((value) => value + 1)}
/>
</div>
);
}
"use client";
// beui.dev/components/agents/image-generation
import { Check, CircleAlert, RotateCcw } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";
import { useEffect, useRef } from "react";
import { EASE_IN_OUT, EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ImageGenerationStatus =
| "queued"
| "generating"
| "refining"
| "complete"
| "error";
export interface ImageGenerationProps {
/** The completed media. Pass an img, Next Image, canvas, video, or custom preview. */
children?: ReactNode;
status?: ImageGenerationStatus;
/** Accessible description. Defaults to a description derived from prompt. */
label?: string;
prompt?: string;
resolution?: string;
/** CSS aspect ratio reserved before generated media is available. */
aspectRatio?: CSSProperties["aspectRatio"];
size?: "compact" | "fluid";
/** Lets the active dither cluster follow fine-pointer movement. */
interactive?: boolean;
statusText?: string;
showStatus?: boolean;
onRetry?: () => void;
className?: string;
mediaClassName?: string;
statusClassName?: string;
}
const STATUS_TEXT: Record<ImageGenerationStatus, string> = {
queued: "Waiting to generate",
generating: "Generating image",
refining: "Refining details",
complete: "Image ready",
error: "Generation failed",
};
const MEDIA_STATE: Record<
ImageGenerationStatus,
{ filter: string; opacity: number; scale: number }
> = {
queued: { filter: "blur(4px) saturate(0.75)", opacity: 0, scale: 1.02 },
generating: { filter: "blur(3px) saturate(0.85)", opacity: 0, scale: 1.015 },
refining: { filter: "blur(1.5px) saturate(0.95)", opacity: 0.62, scale: 1.005 },
complete: { filter: "blur(0px) saturate(1)", opacity: 1, scale: 1 },
error: { filter: "blur(2px) saturate(0.5)", opacity: 0.28, scale: 1 },
};
const OVERLAY_OPACITY: Record<ImageGenerationStatus, number> = {
queued: 1,
generating: 1,
refining: 0.48,
complete: 0,
error: 0,
};
const DOT_GAP = 10;
const TWO_PI = Math.PI * 2;
function DitherMark({
status,
reduce,
}: {
status: ImageGenerationStatus;
reduce: boolean;
}) {
if (status === "complete") {
return <Check aria-hidden="true" className="size-3.5" />;
}
if (status === "error") {
return <CircleAlert aria-hidden="true" className="size-3.5" />;
}
return (
<motion.span
aria-hidden="true"
animate={reduce ? undefined : { rotate: 360 }}
transition={{
duration: 2.4,
ease: EASE_IN_OUT,
repeat: Number.POSITIVE_INFINITY,
}}
className="grid size-3.5 grid-cols-2 place-items-center gap-0.5"
>
<span className="size-1 rounded-[1px] bg-current" />
<span className="size-1 rounded-[1px] bg-current opacity-55" />
<span className="size-1 rounded-[1px] bg-current opacity-55" />
<span className="size-1 rounded-[1px] bg-current" />
</motion.span>
);
}
function DitherField({
interactive,
reduce,
status,
}: {
interactive: boolean;
reduce: boolean;
status: ImageGenerationStatus;
}) {
const canHover = useHoverCapable();
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
let frame = 0;
let width = 0;
let height = 0;
let dotColor = "currentColor";
const pointer = {
x: 0,
y: 0,
targetX: 0,
targetY: 0,
inside: false,
};
const pointerEnabled = interactive && canHover && !reduce;
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width || canvas.clientWidth || 208;
height = rect.height || canvas.clientHeight || 208;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
dotColor = window.getComputedStyle(canvas).color;
pointer.x = width / 2;
pointer.y = height / 2;
pointer.targetX = pointer.x;
pointer.targetY = pointer.y;
};
const draw = (time: number) => {
context.clearRect(0, 0, width, height);
if (!pointer.inside) {
pointer.targetX =
width / 2 + (reduce ? 0 : Math.sin(time / 1700) * width * 0.12);
pointer.targetY =
height / 2 + (reduce ? 0 : Math.cos(time / 2100) * height * 0.1);
}
const follow = reduce ? 1 : pointer.inside ? 0.16 : 0.045;
pointer.x += (pointer.targetX - pointer.x) * follow;
pointer.y += (pointer.targetY - pointer.y) * follow;
const radius = Math.min(width, height) * 0.38;
const columns = Math.ceil(width / DOT_GAP) + 1;
const rows = Math.ceil(height / DOT_GAP) + 1;
const offsetX = (width - (columns - 1) * DOT_GAP) / 2;
const offsetY = (height - (rows - 1) * DOT_GAP) / 2;
context.fillStyle = dotColor;
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const anchorX = offsetX + column * DOT_GAP;
const anchorY = offsetY + row * DOT_GAP;
const deltaX = anchorX - pointer.x;
const deltaY = anchorY - pointer.y;
const distance = Math.hypot(deltaX, deltaY);
const proximity = Math.max(0, 1 - distance / radius);
const influence = proximity * proximity * (3 - 2 * proximity);
const displacement = influence * influence * 9;
const directionX = distance > 0 ? deltaX / distance : 0;
const directionY = distance > 0 ? deltaY / distance : 0;
const x = anchorX + directionX * displacement;
const y = anchorY + directionY * displacement;
const dotRadius = 0.65 + influence * 0.85;
context.globalAlpha = 0.17 + influence * 0.72;
context.beginPath();
context.arc(x, y, dotRadius, 0, TWO_PI);
context.fill();
}
}
context.globalAlpha = 1;
if (!reduce) frame = window.requestAnimationFrame(draw);
};
const handlePointerMove = (event: PointerEvent) => {
if (!pointerEnabled) return;
const rect = canvas.getBoundingClientRect();
pointer.inside = true;
pointer.targetX = event.clientX - rect.left;
pointer.targetY = event.clientY - rect.top;
};
const handlePointerLeave = () => {
pointer.inside = false;
};
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(resize);
resize();
resizeObserver?.observe(canvas);
canvas.addEventListener("pointermove", handlePointerMove, { passive: true });
canvas.addEventListener("pointerleave", handlePointerLeave);
draw(0);
return () => {
if (frame) window.cancelAnimationFrame(frame);
resizeObserver?.disconnect();
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
};
}, [canHover, interactive, reduce]);
return (
<motion.div
aria-hidden="true"
initial={false}
animate={{ opacity: OVERLAY_OPACITY[status] }}
transition={{ duration: reduce ? 0 : 0.4, ease: EASE_OUT }}
className="absolute inset-0 overflow-hidden bg-muted"
>
<canvas
ref={canvasRef}
className="absolute inset-0 size-full text-foreground"
/>
</motion.div>
);
}
export function ImageGeneration({
children,
status = "generating",
label,
prompt,
resolution = "1024 × 1024",
aspectRatio = "1 / 1",
size = "compact",
interactive = true,
statusText,
showStatus = true,
onRetry,
className,
mediaClassName,
statusClassName,
}: ImageGenerationProps) {
const reduce = useReducedMotion() ?? false;
const active =
status === "queued" || status === "generating" || status === "refining";
const mediaState = MEDIA_STATE[status];
const resolvedStatusText = statusText ?? STATUS_TEXT[status];
const resolvedLabel =
label ?? (prompt ? `${resolvedStatusText}: ${prompt}` : resolvedStatusText);
return (
<div
data-slot="image-generation"
data-state={status}
aria-busy={active}
className={cn("w-full", className)}
>
<div
className={cn(
"w-full",
size === "compact" && "mx-auto max-w-52",
)}
>
<div
role="img"
aria-label={resolvedLabel}
style={{ aspectRatio }}
className="relative isolate w-full overflow-hidden rounded-xl bg-muted"
>
<motion.div
aria-hidden={children ? undefined : true}
initial={false}
animate={
reduce
? { opacity: mediaState.opacity }
: {
filter: mediaState.filter,
opacity: mediaState.opacity,
scale: mediaState.scale,
}
}
transition={
reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }
}
className={cn(
"absolute inset-0 [&>*]:size-full [&>*]:object-cover [&_img]:size-full [&_img]:object-cover",
mediaClassName,
)}
>
{children}
</motion.div>
<AnimatePresence initial={false}>
{active ? (
<motion.div
key="dither-field"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.25, ease: EASE_OUT }}
className="absolute inset-0"
>
<DitherField
interactive={interactive}
reduce={reduce}
status={status}
/>
</motion.div>
) : null}
</AnimatePresence>
{resolution ? (
<span className="absolute top-2 right-2 z-10 rounded-full bg-background/75 px-2 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
{resolution}
</span>
) : null}
</div>
{showStatus || prompt ? (
<div className="mt-3 text-left">
{showStatus ? (
<div
aria-live="polite"
className={cn(
"flex min-h-5 items-center gap-2 text-sm font-medium text-foreground",
status === "error" && "text-destructive",
statusClassName,
)}
>
<DitherMark status={status} reduce={reduce} />
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={resolvedStatusText}
initial={reduce ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -4 }}
transition={{
duration: reduce ? 0 : 0.15,
ease: EASE_OUT,
}}
>
{resolvedStatusText}
</motion.span>
</AnimatePresence>
</div>
) : null}
{prompt ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
“{prompt}”
</p>
) : null}
</div>
) : null}
{status === "error" && onRetry ? (
<motion.button
type="button"
onClick={onRetry}
whileTap={reduce ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-full px-3 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw aria-hidden="true" className="size-4" />
Try again
</motion.button>
) : null}
</div>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/agents/image-generation
import { Check, CircleAlert, RotateCcw } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";
import { useEffect, useRef } from "react";
import { EASE_IN_OUT, EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ImageGenerationStatus =
| "queued"
| "generating"
| "refining"
| "complete"
| "error";
export interface ImageGenerationProps {
/** The completed media. Pass an img, Next Image, canvas, video, or custom preview. */
children?: ReactNode;
status?: ImageGenerationStatus;
/** Accessible description. Defaults to a description derived from prompt. */
label?: string;
prompt?: string;
resolution?: string;
/** CSS aspect ratio reserved before generated media is available. */
aspectRatio?: CSSProperties["aspectRatio"];
size?: "compact" | "fluid";
/** Lets the active dither cluster follow fine-pointer movement. */
interactive?: boolean;
statusText?: string;
showStatus?: boolean;
onRetry?: () => void;
className?: string;
mediaClassName?: string;
statusClassName?: string;
}
const STATUS_TEXT: Record<ImageGenerationStatus, string> = {
queued: "Waiting to generate",
generating: "Generating image",
refining: "Refining details",
complete: "Image ready",
error: "Generation failed",
};
const MEDIA_STATE: Record<
ImageGenerationStatus,
{ filter: string; opacity: number; scale: number }
> = {
queued: { filter: "blur(4px) saturate(0.75)", opacity: 0, scale: 1.02 },
generating: { filter: "blur(3px) saturate(0.85)", opacity: 0, scale: 1.015 },
refining: { filter: "blur(1.5px) saturate(0.95)", opacity: 0.62, scale: 1.005 },
complete: { filter: "blur(0px) saturate(1)", opacity: 1, scale: 1 },
error: { filter: "blur(2px) saturate(0.5)", opacity: 0.28, scale: 1 },
};
const OVERLAY_OPACITY: Record<ImageGenerationStatus, number> = {
queued: 1,
generating: 1,
refining: 0.48,
complete: 0,
error: 0,
};
const DOT_GAP = 10;
const TWO_PI = Math.PI * 2;
function DitherMark({
status,
reduce,
}: {
status: ImageGenerationStatus;
reduce: boolean;
}) {
if (status === "complete") {
return <Check aria-hidden="true" className="size-3.5" />;
}
if (status === "error") {
return <CircleAlert aria-hidden="true" className="size-3.5" />;
}
return (
<motion.span
aria-hidden="true"
animate={reduce ? undefined : { rotate: 360 }}
transition={{
duration: 2.4,
ease: EASE_IN_OUT,
repeat: Number.POSITIVE_INFINITY,
}}
className="grid size-3.5 grid-cols-2 place-items-center gap-0.5"
>
<span className="size-1 rounded-[1px] bg-current" />
<span className="size-1 rounded-[1px] bg-current opacity-55" />
<span className="size-1 rounded-[1px] bg-current opacity-55" />
<span className="size-1 rounded-[1px] bg-current" />
</motion.span>
);
}
function DitherField({
interactive,
reduce,
status,
}: {
interactive: boolean;
reduce: boolean;
status: ImageGenerationStatus;
}) {
const canHover = useHoverCapable();
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
let frame = 0;
let width = 0;
let height = 0;
let dotColor = "currentColor";
const pointer = {
x: 0,
y: 0,
targetX: 0,
targetY: 0,
inside: false,
};
const pointerEnabled = interactive && canHover && !reduce;
const resize = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width || canvas.clientWidth || 208;
height = rect.height || canvas.clientHeight || 208;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
dotColor = window.getComputedStyle(canvas).color;
pointer.x = width / 2;
pointer.y = height / 2;
pointer.targetX = pointer.x;
pointer.targetY = pointer.y;
};
const draw = (time: number) => {
context.clearRect(0, 0, width, height);
if (!pointer.inside) {
pointer.targetX =
width / 2 + (reduce ? 0 : Math.sin(time / 1700) * width * 0.12);
pointer.targetY =
height / 2 + (reduce ? 0 : Math.cos(time / 2100) * height * 0.1);
}
const follow = reduce ? 1 : pointer.inside ? 0.16 : 0.045;
pointer.x += (pointer.targetX - pointer.x) * follow;
pointer.y += (pointer.targetY - pointer.y) * follow;
const radius = Math.min(width, height) * 0.38;
const columns = Math.ceil(width / DOT_GAP) + 1;
const rows = Math.ceil(height / DOT_GAP) + 1;
const offsetX = (width - (columns - 1) * DOT_GAP) / 2;
const offsetY = (height - (rows - 1) * DOT_GAP) / 2;
context.fillStyle = dotColor;
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const anchorX = offsetX + column * DOT_GAP;
const anchorY = offsetY + row * DOT_GAP;
const deltaX = anchorX - pointer.x;
const deltaY = anchorY - pointer.y;
const distance = Math.hypot(deltaX, deltaY);
const proximity = Math.max(0, 1 - distance / radius);
const influence = proximity * proximity * (3 - 2 * proximity);
const displacement = influence * influence * 9;
const directionX = distance > 0 ? deltaX / distance : 0;
const directionY = distance > 0 ? deltaY / distance : 0;
const x = anchorX + directionX * displacement;
const y = anchorY + directionY * displacement;
const dotRadius = 0.65 + influence * 0.85;
context.globalAlpha = 0.17 + influence * 0.72;
context.beginPath();
context.arc(x, y, dotRadius, 0, TWO_PI);
context.fill();
}
}
context.globalAlpha = 1;
if (!reduce) frame = window.requestAnimationFrame(draw);
};
const handlePointerMove = (event: PointerEvent) => {
if (!pointerEnabled) return;
const rect = canvas.getBoundingClientRect();
pointer.inside = true;
pointer.targetX = event.clientX - rect.left;
pointer.targetY = event.clientY - rect.top;
};
const handlePointerLeave = () => {
pointer.inside = false;
};
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(resize);
resize();
resizeObserver?.observe(canvas);
canvas.addEventListener("pointermove", handlePointerMove, { passive: true });
canvas.addEventListener("pointerleave", handlePointerLeave);
draw(0);
return () => {
if (frame) window.cancelAnimationFrame(frame);
resizeObserver?.disconnect();
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
};
}, [canHover, interactive, reduce]);
return (
<motion.div
aria-hidden="true"
initial={false}
animate={{ opacity: OVERLAY_OPACITY[status] }}
transition={{ duration: reduce ? 0 : 0.4, ease: EASE_OUT }}
className="absolute inset-0 overflow-hidden bg-muted"
>
<canvas
ref={canvasRef}
className="absolute inset-0 size-full text-foreground"
/>
</motion.div>
);
}
export function ImageGeneration({
children,
status = "generating",
label,
prompt,
resolution = "1024 × 1024",
aspectRatio = "1 / 1",
size = "compact",
interactive = true,
statusText,
showStatus = true,
onRetry,
className,
mediaClassName,
statusClassName,
}: ImageGenerationProps) {
const reduce = useReducedMotion() ?? false;
const active =
status === "queued" || status === "generating" || status === "refining";
const mediaState = MEDIA_STATE[status];
const resolvedStatusText = statusText ?? STATUS_TEXT[status];
const resolvedLabel =
label ?? (prompt ? `${resolvedStatusText}: ${prompt}` : resolvedStatusText);
return (
<div
data-slot="image-generation"
data-state={status}
aria-busy={active}
className={cn("w-full", className)}
>
<div
className={cn(
"w-full",
size === "compact" && "mx-auto max-w-52",
)}
>
<div
role="img"
aria-label={resolvedLabel}
style={{ aspectRatio }}
className="relative isolate w-full overflow-hidden rounded-xl bg-muted"
>
<motion.div
aria-hidden={children ? undefined : true}
initial={false}
animate={
reduce
? { opacity: mediaState.opacity }
: {
filter: mediaState.filter,
opacity: mediaState.opacity,
scale: mediaState.scale,
}
}
transition={
reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }
}
className={cn(
"absolute inset-0 [&>*]:size-full [&>*]:object-cover [&_img]:size-full [&_img]:object-cover",
mediaClassName,
)}
>
{children}
</motion.div>
<AnimatePresence initial={false}>
{active ? (
<motion.div
key="dither-field"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.25, ease: EASE_OUT }}
className="absolute inset-0"
>
<DitherField
interactive={interactive}
reduce={reduce}
status={status}
/>
</motion.div>
) : null}
</AnimatePresence>
{resolution ? (
<span className="absolute top-2 right-2 z-10 rounded-full bg-background/75 px-2 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
{resolution}
</span>
) : null}
</div>
{showStatus || prompt ? (
<div className="mt-3 text-left">
{showStatus ? (
<div
aria-live="polite"
className={cn(
"flex min-h-5 items-center gap-2 text-sm font-medium text-foreground",
status === "error" && "text-destructive",
statusClassName,
)}
>
<DitherMark status={status} reduce={reduce} />
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={resolvedStatusText}
initial={reduce ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -4 }}
transition={{
duration: reduce ? 0 : 0.15,
ease: EASE_OUT,
}}
>
{resolvedStatusText}
</motion.span>
</AnimatePresence>
</div>
) : null}
{prompt ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
“{prompt}”
</p>
) : null}
</div>
) : null}
{status === "error" && onRetry ? (
<motion.button
type="button"
onClick={onRetry}
whileTap={reduce ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-full px-3 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw aria-hidden="true" className="size-4" />
Try again
</motion.button>
) : null}
</div>
</div>
);
}
API Reference
children?ReactNodeThe completed media. Pass an img, Next Image, canvas, video, or custom preview.
—status?"complete" | "error" | "queued" | "generating" | "refining"generatinglabel?stringAccessible description. Defaults to a description derived from prompt.
—prompt?string—resolution?string1024 × 1024aspectRatio?AspectRatioCSS aspect ratio reserved before generated media is available.
1 / 1size?"compact" | "fluid"compactinteractive?booleanLets the active dither cluster follow fine-pointer movement.
truestatusText?string—showStatus?booleantrueonRetry?(() => void)—className?string—mediaClassName?string—statusClassName?string—Composition
Place generated media inside the response row that owns its prompt and completion state.
Message
└── MessageContent
└── ImageGeneration
└── GeneratedMediaNote: Message keeps the generated result attached to its assistant turn. Streaming Response carries accompanying text before or after the media result. Prompt Input collects the instruction that starts generation.
How it works
Generated media should occupy its final space before pixels are ready. The surface keeps one stable canvas while generation state progressively reveals the result, then settles into an ordinary image that remains easy to inspect.
Updated