Components / What it produces

Image generation

An image the agent is making: reserved frame, dither field while queued and generating, media sharpening in as it refines and completes.

Waiting to generate

“Soft abstract shapes for a workspace cover”

pnpm exec shadcn add @intelligo/ai-image-generation

Inside an app made by intelligo create, or any shadcn project that names the registry.

built on

ai-motion, button

npm packages it adds

lucide-react · motion

used by these pages

Chat

more in what it produces

Code block, Markdown, Artifact, Motion, Citations, Sidebar

Source

What shadcn add writes to components/ui/ai-image-generation.tsx. It is yours from then on.

"use client";

/*
 * An image the agent is making: the frame is reserved up front, a dither
 * field breathes over it while it is queued and generating, and the media
 * sharpens into place as it refines and completes — no layout shift at any
 * step.
 */

import * as React from "react";
import { CheckIcon, CircleAlertIcon, RotateCcwIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import {
  EASE_IN_OUT,
  EASE_OUT,
  SPRING_PRESS,
} from "@showcase/components/ui/ai-motion";
import { Button } from "@showcase/components/ui/button";
import { cn } from "@showcase/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?: React.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?: React.CSSProperties["aspectRatio"];
  size?: "compact" | "fluid";
  /** Lets the active dither cluster follow fine-pointer movement. */
  interactive?: boolean;
  /** Overrides the status line for the current status only. */
  statusText?: string;
  /** The status line per status. */
  statusLabels?: Partial<Record<ImageGenerationStatus, string>>;
  showStatus?: boolean;
  onRetry?: () => void;
  retryLabel?: string;
  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;

/** The 10px resolution chip; the type scale has no step this small. */
const TINY_TEXT: React.CSSProperties = { fontSize: "0.625rem" };

/*
 * True only on devices with a real hover (mouse / trackpad). Touch devices
 * fire phantom `:hover` on tap that sticks until tap-elsewhere — hover-only
 * effects are gated behind this.
 */
const HOVER_QUERY = "(hover: hover) and (pointer: fine)";

function subscribeHover(onChange: () => void) {
  const query = window.matchMedia(HOVER_QUERY);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}

function useHoverCapable() {
  return React.useSyncExternalStore(
    subscribeHover,
    () => window.matchMedia(HOVER_QUERY).matches,
    () => false
  );
}

function DitherMark({
  status,
  reduced,
}: {
  status: ImageGenerationStatus;
  reduced: boolean;
}) {
  if (status === "complete") {
    return <CheckIcon aria-hidden="true" className="size-3.5" />;
  }

  if (status === "error") {
    return <CircleAlertIcon aria-hidden="true" className="size-3.5" />;
  }

  return (
    <motion.span
      data-slot="image-generation-mark"
      aria-hidden="true"
      animate={reduced ? 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-xs bg-current" />
      <span className="size-1 rounded-xs bg-current opacity-55" />
      <span className="size-1 rounded-xs bg-current opacity-55" />
      <span className="size-1 rounded-xs bg-current" />
    </motion.span>
  );
}

function DitherField({
  interactive,
  reduced,
  status,
}: {
  interactive: boolean;
  reduced: boolean;
  status: ImageGenerationStatus;
}) {
  const canHover = useHoverCapable();
  const canvasRef = React.useRef<HTMLCanvasElement>(null);

  React.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 && !reduced;

    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 + (reduced ? 0 : Math.sin(time / 1700) * width * 0.12);
        pointer.targetY =
          height / 2 + (reduced ? 0 : Math.cos(time / 2100) * height * 0.1);
      }

      const follow = reduced ? 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;
      // Reduced motion draws the field once and leaves it still.
      if (!reduced) 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, reduced]);

  return (
    <motion.div
      data-slot="image-generation-dither"
      aria-hidden="true"
      initial={false}
      animate={{ opacity: OVERLAY_OPACITY[status] }}
      transition={{ duration: reduced ? 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>
  );
}

function ImageGeneration({
  children,
  status = "generating",
  label,
  prompt,
  resolution = "1024 × 1024",
  aspectRatio = "1 / 1",
  size = "compact",
  interactive = true,
  statusText,
  statusLabels,
  showStatus = true,
  onRetry,
  retryLabel = "Try again",
  className,
  mediaClassName,
  statusClassName,
}: ImageGenerationProps) {
  const reduced = useReducedMotion() ?? false;
  const active =
    status === "queued" || status === "generating" || status === "refining";
  const mediaState = MEDIA_STATE[status];
  const resolvedStatusText =
    statusText ?? statusLabels?.[status] ?? 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
          data-slot="image-generation-frame"
          role="img"
          aria-label={resolvedLabel}
          style={{ aspectRatio }}
          className="relative isolate w-full overflow-hidden rounded-xl bg-muted"
        >
          <motion.div
            data-slot="image-generation-media"
            aria-hidden={children ? undefined : true}
            initial={false}
            animate={
              reduced
                ? { opacity: mediaState.opacity }
                : {
                    filter: mediaState.filter,
                    opacity: mediaState.opacity,
                    scale: mediaState.scale,
                  }
            }
            transition={
              reduced ? { 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: reduced ? 0 : 0.25, ease: EASE_OUT }}
                className="absolute inset-0"
              >
                <DitherField
                  interactive={interactive}
                  reduced={reduced}
                  status={status}
                />
              </motion.div>
            ) : null}
          </AnimatePresence>

          {resolution ? (
            <span
              data-slot="image-generation-resolution"
              className="absolute top-2 right-2 z-10 rounded-full bg-background/75 px-2 py-0.5 font-mono tabular-nums text-muted-foreground"
              style={TINY_TEXT}
            >
              {resolution}
            </span>
          ) : null}
        </div>

        {showStatus || prompt ? (
          <div className="mt-3 text-left">
            {showStatus ? (
              <div
                data-slot="image-generation-status"
                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} reduced={reduced} />
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span
                    key={resolvedStatusText}
                    initial={reduced ? false : { opacity: 0, y: 4 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={reduced ? undefined : { opacity: 0, y: -4 }}
                    transition={{
                      duration: reduced ? 0 : 0.15,
                      ease: EASE_OUT,
                    }}
                  >
                    {resolvedStatusText}
                  </motion.span>
                </AnimatePresence>
              </div>
            ) : null}
            {prompt ? (
              <p
                data-slot="image-generation-prompt"
                className="mt-0.5 truncate text-xs text-muted-foreground"
              >
                “{prompt}”
              </p>
            ) : null}
          </div>
        ) : null}

        {status === "error" && onRetry ? (
          <Button
            data-slot="image-generation-retry"
            type="button"
            variant="ghost"
            onClick={onRetry}
            className="mt-3 min-h-10 px-3 text-foreground"
            render={
              <motion.button
                whileTap={reduced ? undefined : { scale: 0.96 }}
                transition={SPRING_PRESS}
              />
            }
          >
            <RotateCcwIcon aria-hidden="true" className="size-4" />
            {retryLabel}
          </Button>
        ) : null}
      </div>
    </div>
  );
}

export { ImageGeneration };

Ready to try it? Three commands and the app runs against a stub model.