Scrim UI

Image Generation Studio

A one-screen generation studio — prompt composer and model picker beside a feed of queued, staged, blocked and ready image results with variants.

image-studio.tsx210 lines3 componentsReact + Tailwind, no dependencies

npx shadcn@latest add https://scrimui.dev/r/image-studio.json
Agent promptClaude Code · Cursor · any agent
Add the Image Generation Studio pattern from the Scrim UI registry to this project — a complete screen, not a single component.

Image Generation Studio — A one-screen generation studio — prompt composer and model picker beside a feed of queued, staged, blocked and ready image results with variants.

## 1. Install

```bash
npx shadcn@latest add https://scrimui.dev/r/image-studio.json
```

This writes the screen to `components/blocks/image-studio.tsx` and pulls in the components it is built from, each landing at `components/ui/`. Everything is plain React + Tailwind with no runtime dependencies. The imports in the block already point at those paths, so it compiles as installed.

## 2. What it is made of

- `generated-media` — Generated Media Result
- `prompt-input` — Prompt Input
- `model-selector` — Model Selector

Each is a separate file you can edit or replace on its own; the block is the arrangement, not a monolith.

## 3. Rules this layout depends on

Keep these when adapting the screen — they are the reasons it works, and they are easy to break while restyling:

- Put the prompt in the feed the moment it's submitted — a queued card with a position beats a spinner over nothing.
- Stage generation in words and keep the card's shape fixed from queued to ready.
- Return variants per prompt and make switching between them cheap and reversible.
- Split blocked from failed: policy refusals ask for a rephrase, worker failures offer a retry that works.
- Keep every result's prompt one click from the composer — re-use is the core loop of a studio.

## 4. Do not do these

- A generation that vanishes into a global loading state, leaving the user nothing to read or cancel.
- Treating a safety block as an error with Retry — the retry will just be blocked again.
- New results replacing old ones instead of accumulating — a studio is a feed, not a lightbox.
- Params that live only in a tooltip; if the user can't compare two results' settings, variants are lottery tickets.

The demo content in the file — messages, file names, model names — is placeholder. Replace it with this project's real data and wire the handlers to real state rather than shipping the stubs.

Reference: https://scrimui.dev/patterns/image-studio

Installs the screen and every component it is built from, and carries the layout rules from this page so an agent does not restyle them away.

Live Preview

Image

A mountain ridge at dusk, soft gradient sky, minimal illustration

1024×1024seed 4815illustration

Built from these components

Pattern code

This file composes the components above. Copy each component from its page, then this pattern file wires them together.

image-studio.tsx
"use client";

import * as React from "react";
import { PromptInput } from "../../prompt-input/prompt-input";
import { ModelSelector } from "../../model-selector/model-selector";
import { GeneratedMediaResult, type MediaStatus } from "../../generated-media/generated-media";

/** CSS-painted stand-in for a generated image — patterns ship no assets. */
function ImageMock({ hue = 210 }: { hue?: number }) {
  return (
    <div
      role="img"
      aria-label="Generated illustration: a mountain ridge at dusk"
      className="flex h-full min-h-[220px] w-full items-end p-4"
      style={{
        background: `linear-gradient(180deg, hsl(${hue} 60% 75%) 0%, hsl(${hue} 55% 45%) 60%, hsl(${hue} 50% 25%) 100%)`,
      }}
    >
      <svg viewBox="0 0 400 80" className="w-full" aria-hidden="true">
        <path d="M0 80 L90 20 L160 60 L240 10 L320 55 L400 30 L400 80 Z" fill="hsl(0 0% 100% / 0.25)" />
        <path d="M0 80 L120 45 L220 70 L340 40 L400 60 L400 80 Z" fill="hsl(0 0% 0% / 0.2)" />
      </svg>
    </div>
  );
}

/**
 * A one-screen image generation studio.
 *
 * The flow this pattern exists to show:
 *
 * 1. **The queue is visible.** A submitted prompt enters the feed as a
 *    queued card immediately — position stated, no spinner-and-nothing.
 * 2. **Generation is staged, then it settles.** Queued → generating (stage
 *    in words) → ready; the card never changes shape underneath the reader.
 * 3. **Results come back as variants.** One prompt, three takes — picking
 *    one is cheap and reversible.
 * 4. **Blocked and failed are different days.** The second generation in
 *    this script hits the content policy (rephrase, no retry); the third
 *    fails on the worker (retry, which then succeeds).
 * 5. **The prompt is the re-use path.** Clicking a result's prompt loads it
 *    back into the composer.
 */

type Result = {
  id: number;
  kind: "image";
  status: MediaStatus;
  prompt: string;
  params: string[];
  stage?: string;
  queuePosition?: number;
  variants: { id: string }[];
  currentVariantId: string;
  hue: number;
  errorMessage?: string;
  blockedReason?: string;
};

const STAGES = ["Reading the prompt…", "Composing the scene…", "Diffusing latents…", "Upsampling…"];

const MODELS = [
  { id: "fable-image", name: "Fable Image 2", hint: "Quality" },
  { id: "sketch", name: "Sketch Turbo", hint: "Fast drafts" },
];

const INITIAL_RESULTS: Result[] = [
  {
    id: 1,
    kind: "image",
    status: "ready",
    prompt: "A mountain ridge at dusk, soft gradient sky, minimal illustration",
    params: ["1024×1024", "seed 4815", "illustration"],
    variants: [{ id: "v1" }, { id: "v2" }, { id: "v3" }],
    currentVariantId: "v1",
    hue: 210,
  },
];

export function ImageStudioPattern() {
  const [results, setResults] = React.useState<Result[]>(INITIAL_RESULTS);
  const [model, setModel] = React.useState("fable-image");
  const [draftPrompt, setDraftPrompt] = React.useState("");
  const idRef = React.useRef(2);
  const timers = React.useRef<number[]>([]);

  function patch(id: number, p: Partial<Result>) {
    setResults((rs) => rs.map((r) => (r.id === id ? { ...r, ...p } : r)));
  }

  function schedule(id: number, fn: () => void, ms: number) {
    timers.current.push(window.setTimeout(fn, ms));
  }

  /** The scripted outcomes: 1st new generation succeeds, 2nd is policy-
      blocked, 3rd fails (its Retry succeeds), 4th+ succeed again. */
  function runGeneration(prompt: string) {
    const id = idRef.current++;
    const attempt = results.length; // initial card counts as a past success
    const outcome = attempt % 3 === 1 ? "blocked" : attempt % 3 === 2 ? "failed" : "ready";
    const hue = [160, 330, 45, 260][id % 4];

    const base: Result = {
      id,
      kind: "image",
      status: "queued",
      prompt,
      params: ["1024×1024", `seed ${1000 + id * 37}`, model === "sketch" ? "draft" : "illustration"],
      queuePosition: 2,
      variants: [{ id: "v1" }, { id: "v2" }, { id: "v3" }],
      currentVariantId: "v1",
      hue,
    };
    setResults((rs) => [base, ...rs]);

    schedule(id, () => patch(id, { status: "generating", stage: STAGES[0], queuePosition: undefined }), 1200);
    STAGES.forEach((s, i) => schedule(id, () => patch(id, { stage: s }), 1200 + 700 * i));
    const settleAt = 1200 + 700 * STAGES.length;
    if (outcome === "ready") {
      schedule(id, () => patch(id, { status: "ready", stage: undefined }), settleAt);
    } else if (outcome === "blocked") {
      schedule(
        id,
        () =>
          patch(id, {
            status: "blocked",
            stage: undefined,
            blockedReason: "The prompt names a real public figure. Describe a fictional character or scene instead.",
          }),
        settleAt,
      );
    } else {
      schedule(
        id,
        () => patch(id, { status: "failed", stage: undefined, errorMessage: "The worker ran out of memory mid-generation." }),
        settleAt,
      );
    }
  }

  function retry(r: Result) {
    patch(r.id, { status: "generating", stage: STAGES[1], errorMessage: undefined });
    schedule(r.id, () => patch(r.id, { status: "ready", stage: undefined }), 1600);
  }

  function regenerate(r: Result) {
    const order = ["v1", "v2", "v3"];
    const next = order[(order.indexOf(r.currentVariantId) + 1) % order.length];
    patch(r.id, { status: "generating", stage: STAGES[2], currentVariantId: next });
    schedule(r.id, () => patch(r.id, { status: "ready", stage: undefined }), 1400);
  }

  return (
    <div className="flex h-[640px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
      {/* Composer rail */}
      <aside className="hidden w-72 shrink-0 flex-col gap-3 border-r border-zinc-200 p-3 dark:border-zinc-800 md:flex">
        <p className="text-[13px] font-semibold text-zinc-900 dark:text-zinc-100">Image Studio</p>
        <ModelSelector options={MODELS} value={model} onSelect={setModel} />
        {draftPrompt && (
          <p className="rounded-lg bg-zinc-50 px-2.5 py-2 text-[11px] leading-4 text-zinc-500 dark:bg-zinc-800/60 dark:text-zinc-400">
            Reusing:{draftPrompt}</p>
        )}
        <div className="mt-auto">
          <PromptInput
            placeholder="Describe the image…"
            onSubmit={(v) => {
              runGeneration(v);
              setDraftPrompt("");
            }}
          />
        </div>
      </aside>

      {/* Results feed */}
      <div className="flex min-w-0 flex-1 flex-col">
        <div className="border-b border-zinc-200 px-4 py-3 dark:border-zinc-800 md:hidden">
          <PromptInput
            placeholder="Describe the image…"
            onSubmit={(v) => runGeneration(v)}
          />
        </div>
        <div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
          {results.map((r) => (
            <GeneratedMediaResult
              key={r.id}
              kind={r.kind}
              status={r.status}
              prompt={r.prompt}
              params={r.params}
              stage={r.stage}
              queuePosition={r.queuePosition}
              variants={r.variants}
              currentVariantId={r.currentVariantId}
              onVariantChange={(vid) => patch(r.id, { currentVariantId: vid })}
              errorMessage={r.errorMessage}
              blockedReason={r.blockedReason}
              onDownload={() => {}}
              onRegenerate={() => regenerate(r)}
              onRetry={() => retry(r)}
              onCancel={() => patch(r.id, { status: "cancelled", stage: undefined })}
            >
              <ImageMock hue={r.hue + (r.currentVariantId === "v2" ? 30 : r.currentVariantId === "v3" ? -30 : 0)} />
            </GeneratedMediaResult>
          ))}
        </div>
      </div>
    </div>
  );
}

When to use it

  • Put the prompt in the feed the moment it's submitted — a queued card with a position beats a spinner over nothing.
  • Stage generation in words and keep the card's shape fixed from queued to ready.
  • Return variants per prompt and make switching between them cheap and reversible.
  • Split blocked from failed: policy refusals ask for a rephrase, worker failures offer a retry that works.
  • Keep every result's prompt one click from the composer — re-use is the core loop of a studio.

What breaks in production

  • A generation that vanishes into a global loading state, leaving the user nothing to read or cancel.
  • Treating a safety block as an error with Retry — the retry will just be blocked again.
  • New results replacing old ones instead of accumulating — a studio is a feed, not a lightbox.
  • Params that live only in a tooltip; if the user can't compare two results' settings, variants are lottery tickets.

More Patterns

AI Chat

The canonical chat interface — sidebar, streaming messages, prompt input with model selector, and sources.

AI Research Assistant

A research flow that shows search tool calls, reasoning, sources and a cited final answer.

AI Coding Agent

A coding run with agent status, tool calls, diffs and a human-in-the-loop approval gate.

AI Voice Assistant

A voice-first conversation — live waveform states, a recording input, a spoken transcript and a typed fallback.

Model & Memory Preferences

A preferences screen that picks the model, reasoning level and tools, and manages persistent memory.

Artifact Workspace

Chat on the left, generated output on the right — artifacts open from the answer, stream, version, and fail without breaking the conversation.

Document Q&A Workspace

Ask your own documents — upload and parse, cited answers with inspectable passages, an honest not-found state, and a visible context budget.

Structured Extraction & Review

Upload a document, watch fields fill in, then review the flagged ones — per-field confidence, corrections that keep the original, export earned.

Multi-agent Ops Console

Watch a fleet of agents at once — parallel statuses, an inspectable handoff, a waiting approval, a failed child run, and per-run plus fleet cost.

Customer Support Copilot

Grounded reply drafts with citations, honest low-confidence answers, inline corrections, an approval gate on refunds, and a rating row on every draft.

Generative UI Dashboard

The model assembles a dashboard from a controlled widget registry — streamed props, an unsupported-request fallback, and widget clicks that re-enter the chat.