# Image Studio

> A prompt-to-image workspace for apps built on image models. Results come into focus as the model works, and any image can be refined from the composer.

Canonical: https://hextaui.com/blocks/image-studio
Last updated: 2026-10-29

## Overview

Image models take seconds per image, fail one image at a time and return results people want to keep working on. Image Studio covers that loop the way a chat does: history reads down to a composer pinned at the bottom, and each prompt lands as a new batch right above it.

While the model works, every placeholder sits at the exact final shape and comes into focus. A blue dither field (or ASCII glyphs with developEffect) flows across each tile while the image draws itself in its own colours from top to bottom behind a wavy edge, driven by the progress you report. The pointer swirls and lights the dots it passes over, a click or tap sends a ripple, and when the final image lands a bulge rolls up from the bottom, then the dither eases through pixel blocks into the real image. One line above the batch names the phase, like Composing or Adding detail, with the percentage and elapsed time, the same pattern as the Thinking block.

Providers stream differently, and the field only draws what you yield. If your API sends partial images (OpenAI partial_images, ComfyUI or fal previews), yield each one as preview on a progress event. If it reports steps, yield step and steps. If it only says pending, yield an estimated progress and the field stays a quiet blue wait until done. The default scan reveal is stylised: models don't paint from top to bottom, so it maps progress onto rows as a look, not a readout. Set developReveal="refine" to show only what the model returned: partial images dissolve in across the whole frame and sharpen from coarse blocks as progress rises, the way diffusion refines all of an image at once. Both modes read the same events.

The common path stays one gesture each: type and press Enter, hover a result and press Refine, describe the change. Aspect ratio and style sit in the composer, and count, model, seed and a negative prompt wait in one Options popover. Vary, upscale, region edits, reuse, download and delete live in the image menu and the viewer.

Rows keep every batch next to its prompt, never shift while loading, and move in reading order. Failed images show the reason and a Retry that reuses their seed and the batch’s own runner.

## Installation

### Step 1: Get HextaUI Pro

One payment unlocks every block, including future ones. [See pricing](https://hextaui.com/pricing).

### Step 2: Add the Pro registry to components.json

```json title="components.json"
{
  "registries": {
    "@hextaui-pro": {
      "url": "https://hextaui.com/r/pro/{name}.json",
      "headers": {
        "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
      }
    }
  }
}
```

### Step 3: Add your token

Create a token on your [account page](https://hextaui.com/account) and put it in `.env.local` as `HEXTAUI_PRO_TOKEN`.

### Step 4: Add the block

```bash
npx shadcn@latest add @hextaui-pro/image-studio
```

## Usage

### Polling an image API

Write onGenerate as an async generator. Start a job, poll it with sleep(ms, signal) and yield queued, progress, done or error per image. Cancel aborts the signal and calls onCancel, and onComplete hands you each finished batch to save.

```tsx
"use client"

import * as React from "react"

import { ImageStudio, sleep, type Generation, type ImageEvent, type ImageRunner } from "@/components/blocks/image-studio/image-studio"

type Job = {
  id: string
  images: {
    status: "queued" | "running" | "succeeded" | "failed"
    progress?: number
    url?: string
    width?: number
    height?: number
    seed?: number
    error?: string
  }[]
}

async function json<T>(response: Response): Promise<T> {
  if (!response.ok) throw new Error((await response.text()) || "The image service didn’t respond.")
  return response.json() as Promise<T>
}

const generate: ImageRunner = async function* (request, { signal }) {
  const job = await json<Job>(
    await fetch("/api/images", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        id: request.id,
        kind: request.kind,
        prompt: request.settings.prompt,
        negativePrompt: request.settings.negativePrompt,
        aspectRatio: request.settings.aspectRatio,
        model: request.settings.model,
        style: request.settings.preset,
        seeds: request.seeds,
        sourceUrl: request.source?.image.fullSrc ?? request.source?.image.src,
        strength: request.strength,
      }),
      signal,
    })
  )

  const finished = new Set<number>()
  while (finished.size < request.seeds.length) {
    await sleep(1000, signal)
    const current = await json<Job>(await fetch(`/api/images/${job.id}`, { signal }))
    for (const [index, image] of current.images.entries()) {
      if (finished.has(index)) continue
      let event: ImageEvent
      if (image.status === "succeeded" && image.url) {
        finished.add(index)
        event = {
          type: "done",
          index,
          image: { src: image.url, width: image.width, height: image.height, seed: image.seed },
        }
      } else if (image.status === "failed") {
        finished.add(index)
        event = { type: "error", index, message: image.error ?? "This image failed.", retryable: true }
      } else if (image.status === "queued") {
        event = { type: "queued", index }
      } else {
        event = { type: "progress", index, progress: image.progress }
      }
      yield event
    }
  }
}

export function Studio({ initial }: { initial: Generation[] }) {
  const [generations, setGenerations] = React.useState(initial)

  return (
    <ImageStudio
      generations={generations}
      models={[{ value: "image-2", label: "Image 2", credits: 2 }]}
      onGenerate={generate}
      onVary={generate}
      onUpscale={generate}
      onCancel={async (request) => {
        await fetch(`/api/images/${request.id}`, { method: "DELETE" })
      }}
      onComplete={(generation) => {
        setGenerations((list) => [generation, ...list.filter((item) => item.id !== generation.id)])
        void fetch("/api/generations", { method: "PUT", body: JSON.stringify(generation) })
      }}
      onFavorite={async (image, favorite) => {
        await json(await fetch(`/api/images/${image.id}/favorite`, { method: favorite ? "PUT" : "DELETE" }))
      }}
      onDelete={async (image) => {
        await json(await fetch(`/api/images/${image.id}`, { method: "DELETE" }))
      }}
    />
  )
}
```

### Editing a region

onEdit runs both edits. Refine sends request.source and the new prompt with no mask. Edit a region adds a black-and-white PNG mask at the image’s full size, ready for an inpainting endpoint. Use MaskEditor on its own to add region edits to any image you already show.

```tsx
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"

import {
  createImageEventQueue,
  ImageStudio,
  MaskEditor,
  type GeneratedImage,
  type Generation,
  type ImageRunner,
} from "@/components/blocks/image-studio/image-studio"

const edit: ImageRunner = (request, { signal }) => {
  const queue = createImageEventQueue()
  const body = new FormData()
  body.set("prompt", request.settings.prompt)
  body.set("image", request.source?.image.fullSrc ?? request.source?.image.src ?? "")
  body.set("seeds", request.seeds.join(","))
  if (request.mask) body.set("mask", request.mask, "mask.png")

  fetch("/api/images/edit", { method: "POST", body, signal })
    .then(async (response) => {
      if (!response.ok) throw new Error("The edit didn’t go through.")
      const { urls } = (await response.json()) as { urls: string[] }
      urls.forEach((src, index) => queue.push({ type: "done", index, image: { src } }))
      queue.end()
    })
    .catch((error) => queue.fail(error))

  return queue
}

export function StudioWithEdits({ generations }: { generations: Generation[] }) {
  return (
    <ImageStudio
      generations={generations}
      models={[{ value: "image-2", label: "Image 2" }]}
      onGenerate={edit}
      onEdit={edit}
    />
  )
}

export function EditButton({ image, generation }: { image: GeneratedImage; generation: Generation }) {
  const [open, setOpen] = React.useState(false)

  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Edit region
      </Button>
      <MaskEditor
        open={open}
        onOpenChange={setOpen}
        image={image}
        generation={generation}
        onSubmit={async ({ prompt, mask }) => {
          const body = new FormData()
          body.set("prompt", prompt)
          body.set("image", image.fullSrc ?? image.src ?? "")
          body.set("mask", mask, "mask.png")
          const response = await fetch("/api/images/edit", { method: "POST", body })
          if (!response.ok) throw new Error("The edit didn’t go through. Try again.")
        }}
      />
    </>
  )
}
```

### As a chat tool result

useImageRuns, ImageGeneration and ImageComposer work without the full studio. Render them in a tool result and stream events from Server-Sent Events through createImageEventQueue.

```tsx
"use client"

import * as React from "react"

import {
  createImageEventQueue,
  defaultSettings,
  ImageComposer,
  ImageGeneration,
  useImageRuns,
  type ImageEvent,
  type ImageRunner,
  type ImageSettings,
} from "@/components/blocks/image-studio/image-studio"

const models = [{ value: "image-2", label: "Image 2", credits: 2 }]

const stream: ImageRunner = (request, { signal }) => {
  const queue = createImageEventQueue()
  const params = new URLSearchParams({
    prompt: request.settings.prompt,
    aspectRatio: request.settings.aspectRatio,
    seeds: request.seeds.join(","),
    kind: request.kind,
  })
  const source = new EventSource(`/api/images/stream?${params}`)
  source.onmessage = (message) => queue.push(JSON.parse(message.data) as ImageEvent)
  source.addEventListener("end", () => {
    source.close()
    queue.end()
  })
  source.onerror = () => {
    source.close()
    queue.fail(new Error("Lost the connection to the image service."))
  }
  signal.addEventListener("abort", () => source.close(), { once: true })
  return queue
}

export function ImageToolResult({ input }: { input: Partial<ImageSettings> & { prompt: string } }) {
  const [settings, setSettings] = React.useState<ImageSettings>({ ...defaultSettings, count: 2, ...input })
  const runs = useImageRuns()
  const started = React.useRef(false)

  React.useEffect(() => {
    if (started.current) return
    started.current = true
    runs.start(stream, { kind: "generate", settings })
  }, [runs, settings])

  return (
    <div className="flex flex-col gap-4 rounded-2xl border p-3">
      {runs.live.map((generation) => (
        <ImageGeneration
          key={generation.id}
          generation={generation}
          models={models}
          headingLevel={3}
          onCancel={() => runs.cancel(generation.id)}
          onRetry={(image) => runs.retry(stream, generation, image)}
          onVary={(image, from, strength) =>
            runs.start(stream, { kind: "variation", settings: from.settings, source: { generation: from, image }, strength })
          }
          onUpscale={(image, from) =>
            runs.start(stream, {
              kind: "upscale",
              settings: { ...from.settings, count: 1 },
              source: { generation: from, image },
            })
          }
        />
      ))}
      <ImageComposer
        settings={settings}
        onSettingsChange={setSettings}
        models={models}
        placeholder="Refine this image"
        onSubmit={(next, reference) => {
          runs.start(stream, { kind: "generate", settings: next, reference })
        }}
      />
    </div>
  )
}
```

## Anatomy

The parts you compose, from the outside in.

| Part | Description |
| --- | --- |
| `ImageStudio` | The workspace: a header slot with search and favorites, the day-grouped feed, the bottom composer, the viewer and the region editor. Owns runs, refinement, optimistic favorites and undoable deletes. |
| `ImageComposer` | Prompt input with a reference image, aspect ratio, style, the Options popover (count, model, seed, avoid) and a credits hint. Shows a Refining chip when it is editing an image. |
| `ImageGeneration` | One batch: prompt, a live phase line with the orb while it runs, then settings, palette and duration, above a grid at the batch’s aspect ratio. |
| `ImageTile` | One image. Comes into focus as progress rises, reveals the result, shows errors with Retry, and offers Refine, Vary and more on hover or focus. |
| `DevelopField` | The developing surface: a canvas of dithered dots, or monospace glyphs, in the theme's info blue that renders streamed previews in their own colours, settling from the top or, with refine, sharpening across the frame. Canvas only, paused off-screen, still under reduced motion. |
| `ImageLightbox` | Full-size viewer with zoom, pan, arrow keys between results, every setting, and Refine as the primary action. |
| `MaskEditor` | Paint or erase a mask with a mouse, finger or the keyboard, then describe what goes there. |
| `useImageRuns` | The run engine. Starts, retries and cancels runs, applies events, times each batch and reports finished batches. |

## API reference

### ImageStudio

Also accepts every div prop except children.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `generations` | `Generation[]` | – | Saved history, newest first or in any order. Local runs are merged in by id. |
| `models` | `ImageModel[]` | – | Models to pick from. credits drives the hint, maxCount caps the count, and supportsNegative and supportsReference hide those controls. |
| `presets` | `ImagePreset[]` | `[]` | Style presets. The picker hides when empty. |
| `onGenerate` | `ImageRunner` | – | (request, { signal }) => AsyncIterable<ImageEvent>. Yield queued (with an optional position in line), progress (progress or step and steps, plus an optional preview), done and error events per image index. Retry uses the runner that made the batch. |
| `onVary` | `ImageRunner` | – | Runs a variation. request.source holds the image and request.strength is subtle or strong. Hidden when missing. |
| `onUpscale` | `ImageRunner` | – | Runs an upscale of request.source. Hidden when missing. |
| `onEdit` | `ImageRunner` | – | Runs edits of request.source with the new prompt: Refine sends no mask, Edit a region adds request.mask as a PNG Blob, white where the person painted. Both are hidden when missing. |
| `onCancel` | `(request: ImageRequest) => void \| Promise<void>` | – | Called after Cancel aborts a run, so you can stop the job on the server. |
| `onComplete` | `(generation: Generation) => void` | – | Called when a run settles, including cancelled and partly failed ones, with duration in milliseconds. Save it to your history here. |
| `onFavorite` | `(image, favorite, generation) => void \| Promise<void>` | – | Applied at once. A rejection rolls it back and shows the error. |
| `onDelete` | `(image, generation) => void \| Promise<void>` | – | Called once the Undo window closes. A rejection brings the image back with an error. |
| `onLoadMore` | `() => void \| Promise<void>` | – | Loads older history when the top of the feed comes into view. The scroll position holds while it prepends. |
| `hasMore` | `boolean` | `false` | Whether older history exists. |
| `loading` | `boolean` | `false` | Shows skeleton batches while the first page of history loads. |
| `upload` | `PromptUpload` | – | Uploads a reference image with progress. Without it, the file is passed in request.reference.file. |
| `defaultSettings` | `Partial<ImageSettings>` | – | Starting aspect ratio, count, model, style and seed. |
| `suggestions` | `{ label, prompt, aspectRatio? }[]` | `[]` | Prompt ideas shown when the history is empty. Picking one fills the composer and its aspect ratio. |
| `header` | `ReactNode` | – | Your brand, credits or account, shown at the start of the top bar. Search and favorites sit at its end once there is history. |
| `headerClassName` | `string` | – | Classes for the top bar, such as extra end padding for your own floating controls. |
| `developEffect` | `"ascii" \| "dither"` | `"dither"` | How a generating image draws itself: coloured ASCII glyphs or an ordered dither of coloured dots. Both take the same hover, ripple, bulge and pixel reveal. |
| `developReveal` | `"refine" \| "scan"` | `"scan"` | scan is a stylised top-to-bottom draw driven by progress. refine shows partial images across the whole tile and sharpens them with progress, matching how models stream. |
| `autoFocus` | `boolean` | `false` | Focuses the composer on mount, without scrolling the page. Leave it off when the studio is embedded below other content, so the page keeps its scroll position and keyboard focus. |
| `undoTimeout` | `number` | `5000` | How long Undo stays available after a delete, in milliseconds. 0 deletes at once. |
| `now` | `Date` | – | Fixes the date used for Today and Yesterday, for tests and server rendering. |

### useImageRuns

Returns { live, start, retry, cancel, remove }.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onComplete` | `(generation: Generation) => void` | – | Called when a run settles. |
| `onCancel` | `(request: ImageRequest) => void \| Promise<void>` | – | Called after a run is cancelled. |
| `announce` | `(message: string) => void` | – | Receives short status messages for a live region. |

### createImageEventQueue

Turns callback or event-stream APIs into the async iterator a runner returns.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `push` | `(event: ImageEvent) => void` | – | Queues an event. |
| `end` | `() => void` | – | Ends the stream. Images still unfinished are marked as failed. |
| `fail` | `(error: unknown) => void` | – | Ends the stream with an error shown on every unfinished image. |

## Keyboard

| Key | Action |
| --- | --- |
| `Enter` | Generates from the prompt, or refines the picked image. Shift+Enter adds a line, and ↑ in an empty prompt brings back the last one. |
| `Esc` | In the composer, stops refining and goes back to a fresh prompt. In search, clears and closes it. |
| `←` `→` `↑` `↓` | Moves between images in the feed. The grid is one tab stop, and arrows follow the layout across batches. |
| `Home` `End` | Jumps to the oldest or newest image in the feed. |
| `Enter` | Opens the focused image in the viewer, where ← and → move between results and Esc closes. |
| `R` | Refines the focused image: the composer picks it up and asks what to change. |
| `F` | Favorites the focused image. |
| `V` | Starts a subtle variation. Shift+V, or Shift-clicking Vary, starts a strong one. |
| `U` | Upscales the focused image. |
| `E` | Opens Edit a region. In the editor, arrows move the brush, Space starts or stops painting, [ and ] change its size, and ⌘Z undoes a stroke. |
| `Delete` | Deletes the focused image, with Undo in the toast. |

## Accessibility

- Every image is labelled from its prompt and position, such as “Image 2 of 4: A lone lighthouse…”, and placeholders add their state, such as Queued, In line, 2nd, Step 12 of 30 or Timed out.
- Starts, finished batches, failures, refinements, favorites and deletes are announced through a polite live region with one short message per change, not one per step. Running batches are aria-busy.
- Status never relies on color. The phase line is text with a percentage, tiles show step counts, errors carry an icon and text, favorites use a filled icon and aria-pressed, and the Options dot is repeated in the button’s label.
- With a fine pointer, image actions appear on hover or focus. On touch screens tiles stay clean and a tap opens the viewer, whose actions are 44px tall. Focus moves to a neighbour after a delete and returns to the image after Undo or closing the viewer.
- The dither or ASCII field draws every tile from one shared loop at up to 30fps, pauses off-screen and holds a still frame under reduced motion that still tracks progress. The reveal becomes a short fade, and forced-colors mode drops the field while keeping borders and focus rings.

## Built with

The free HextaUI components Image Studio is made from. Each one installs on its own.

- [Button](https://hextaui.com/docs/button) - Buttons in every variant and size, with a built-in loading, success and error flow that skips the spinner for fast requests.
- [Button group](https://hextaui.com/docs/button-group) - Buttons joined into one control, with shared seams, separators, text addons, nesting and vertical stacks.
- [Dialog](https://hextaui.com/docs/dialog) - A window over the page for forms and focused tasks, with a pinned header and footer, nesting, and a swipeable bottom sheet on phones.
- [Dropdown menu](https://hextaui.com/docs/dropdown-menu) - A menu of actions and options behind a button, with groups, submenus, checkbox and radio items, and shortcuts.
- [Input](https://hextaui.com/docs/input) - A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
- [Input group](https://hextaui.com/docs/input-group) - An input with icons, text, buttons or a keyboard hint attached, sharing one border and focus ring.
- [Kbd](https://hextaui.com/docs/kbd) - Key caps for shortcuts that show the right symbols on every platform, read them out properly and press down with the real keys.
- [Message scroller](https://hextaui.com/docs/message-scroller) - A chat scroll area that follows new messages, keeps your place while you read back, and counts what you missed on the jump button.
- [Number flow](https://hextaui.com/docs/number-flow) - Animated numbers where only the changed digits spin, with any Intl format and locale.
- [Popover](https://hextaui.com/docs/popover) - A floating panel anchored to a trigger that resizes smoothly with its content and follows the trigger’s direction.
- [Select](https://hextaui.com/docs/select) - Pick one or more options from a list that opens on the current value, with typeahead, groups and form support.
- [Slider](https://hextaui.com/docs/slider) - Pick a value or a range by dragging, tapping the track or using the keys, with jumps that glide and an optional value bubble.
- [Spinner](https://hextaui.com/docs/spinner) - A loading indicator with Apple-style ticks or a breathing ring that can wait before showing and stay long enough not to flicker.
- [Textarea](https://hextaui.com/docs/textarea) - Multi-line text that grows smoothly with what you write between a minimum and maximum number of rows, with counters, validation and a submit shortcut.
- [Toast](https://hextaui.com/docs/toast) - Brief messages that stack neatly, expand on hover, swipe away, and turn a loading state into success or error in place.
- [Toggle](https://hextaui.com/docs/toggle) - A button that stays on or off, with a fill that settles in when pressed, a clear hover-to-on step and icons that can fill with the state.
- [Toggle group](https://hextaui.com/docs/toggle-group) - A row of toggles for one choice or many, with a fill that slides to the picked item, joined or spaced items and arrow-key focus.
- [Tooltip](https://hextaui.com/docs/tooltip) - A short hint on hover or keyboard focus that opens after a brief rest, switches instantly between neighbours and shows shortcuts.

## Code

12 files, added to components/blocks/image-studio.
