图像生成
New稳定的生成图画面:排队→生成→精修→完成/可恢复错误,逐步解析媒体且不 remount、不挤压周围会话。
1024 × 1024
Waiting to generate
“a quiet mountain landscape at sunset”
TSXcomponents/previews/blocks/image-generation.preview.tsx
"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>
);
}
TSXcomponents/agents/image-generation.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/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>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/image-generation
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/hooks/use-hover-capable.ts
"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;
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/agents/image-generation.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/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 参考
children?ReactNodeThe completed media. Pass an img, Next Image, canvas, video, or custom preview.
—status?"error" | "complete" | "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—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.