AI

Image Generation

An image-generation result card — the prompt above a variations grid that blurs up and scans while it renders, per-image download / upscale / vary, a first-class safety-blocked state, and the seed behind whichever variation you pick.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Check,
  Copy,
  CopyX,
  Cpu,
  Download,
  Hash,
  ImageOff,
  Images,
  LoaderCircle,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/image-generation.json

Prompt

Build a React + TypeScript + Tailwind "ImageGeneration" card with zod and
lucide-react. It renders ONE image-generation job: the prompt, the variations it
produced, and what you can do with them.

Contract
- A zod schema (`imageGenerationJobSchema` in a sibling contract file) is the
  single source of truth: { id, prompt, state, images[], progress?,
  blockedReason?, blockedCategories?, error?, model?, seed?, size?, aspectRatio?,
  negativePrompt? }.
- The job state union is FIVE values: "queued" | "generating" | "complete" |
  "blocked" | "failed". `blocked` is a safety refusal — a successful answer with
  no pixels in it, not retryable by pressing the same button — and `failed` is a
  model/transport error, which is. Folding them together teaches the reader to
  retry a request that can never succeed.
- One image: { id, state?, url?, previewUrl?, alt?, selected?, progress?, seed?,
  upscale?, pending?, reason? }. `state` is "pending" | "partial" | "ready" |
  "blocked" | "failed" and is OPTIONAL — derive it from what is present (url →
  ready, previewUrl → partial, neither → pending) so a minimal backend can send
  `{ id, url }`. Export that derivation from the contract file, not from the
  component: the data layer needs the same answer to decide whether to keep
  polling.
- The slot lifecycle is NOT the job lifecycle. Diffusion backends return
  variations one at a time and expose a low-res frame of a picture that is still
  denoising, so the grid must be able to say "two done, one half-drawn, one not
  started" instead of blanking until the last pixel lands.
- `progress` (job and slot) is a plain 0…1 number, NOT `z.number().min(0).max(1)`:
  a provider reporting 1.04 must not make the job fail to parse and blank the
  card. Clamp in the component; treat non-finite as unknown.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  card's own render state. `status="error"` means the job RECORD failed to load
  (retry re-fetches); a generation that died is `status="ready"` +
  `job.state="failed"` (retry re-runs the model). The two must read differently.
- Props: job: Job | null; status; selectedId? (controlled, null allowed);
  onSelectImage?(id, image); onDownload?(image); onUpscale?(image);
  onVary?(image); onCancel?(jobId); onRegenerate?(jobId); onEditPrompt?(jobId);
  onRetry?(); columns? (2); promptLines? (2); showFooter? (true); emptyState?;
  errorMessage?; label? ("Image generation"); className plus the rest of the
  element props, ref forwarded to the <section>.
- Numeric props are clamped (columns 1…6, promptLines 1…12) so a 0 or a NaN can
  never produce an empty grid.

Behavior
- PROGRESSIVE REVEAL, three layers deep. A pending slot is a pulsing box; a
  partial slot paints `previewUrl` scaled 110% and blurred (the blur-up) under a
  vertical scan sweep; a ready slot cross-fades the full image in over that same
  preview on decode. The preview stays mounted and fades OUT — unmounting it on
  the tick the final one fades in flashes the empty box between them.
- Cached images need an explicit probe: an <img> can already be `complete`
  before React attaches onLoad, and that load event never fires again. In the ref
  callback, if the node is complete and unhandled, settle it immediately —
  naturalWidth 0 means the request already failed and no error event is coming
  either. Without this, a cached picture sits at opacity 0 under its own blur
  forever. Keep the map keyed by URL, not by image id: an upscale swaps the URL
  under the same id.
- PERCENT IF KNOWN, NEVER INVENTED. A job with `progress` renders a determinate
  bar with aria-valuenow; a job without one renders an INDETERMINATE bar with no
  aria-valuenow at all (a 0 would be announced as "0 percent" for a job that is
  half done). Same rule inside a slot: a number if there is one, the word
  "Rendering" if there is not.
- SELECTION IS A RADIOGROUP, not a pile of buttons. Only slots that actually
  hold a picture are radios; the grid is ONE tab stop (roving tabindex) that
  lands on the selected image or the first selectable one. Left/Right step,
  Up/Down move a ROW (the column count), Home/End jump, everything wraps, and —
  radio semantics — an arrow both moves focus and selects. Non-selectable slots
  are skipped, never focused into a dead end.
- Selection precedence: the `selectedId` prop when it is passed (including null),
  otherwise the reader's own pick, otherwise the image the DATA arrived with
  (`selected: true`). A new `job.id` resets the local pick and the prompt
  disclosure — adjust that during render, not in an effect, so a recycled card
  never paints one frame with the previous job's ring.
- ACTIONS NEVER FAKE. Upscale and Vary render only when their handler exists.
  Download degrades instead: with `onDownload` it is a button, without it a plain
  anchor at `image.url` (cross-origin URLs ignore the download hint and open in a
  new tab) — never a dead button. The actions live in an overlay that is a
  SIBLING of the radio, not a child: interactive content nested inside a radio is
  invalid and unreachable. It is revealed on hover, on focus-within (so the
  keyboard path is identical) and permanently on the selected slot, which is the
  touch story; `pointer-events` tracks visibility so an invisible toolbar cannot
  swallow a tap meant for the image.
- Follow-ups are one-shot and consumer-owned: clicking Upscale calls your
  handler, which sets `image.pending` — the slot then shows a busy chip and the
  control stops re-firing while it is set (aria-disabled, never the native
  `disabled`, which blurs the button mid-action and drops it out of the tab
  order). The card holds no lock of its own, so set `pending` in the same tick
  you kick the request off, not when the response lands, or nothing stops a
  second click for the whole round trip. An image with `upscale` already set
  retires its Upscale control entirely.
- A blocked job draws NO pixels even if the array still carries URLs — the
  refusal is the answer. It names the categories and offers exactly one action,
  editing the prompt. A blocked or failed SINGLE slot is different: its siblings
  stay usable, so only that one cell turns into a shield or a broken-image tile
  with its `reason` in it.
- The card owns NO CLOCK and simulates NO progress: every frame it draws comes
  from the data it was handed, so an SSR pass, a replayed stream and a screenshot
  fixture are identical. The only timer in the file resets the "Copied" label,
  and it is cleared on unmount.
- The prompt is clamped by CHARACTER COUNT with an explicit "Show full prompt"
  toggle — no ResizeObserver, no layout read, same output on the server. Copy
  always writes the whole prompt, never the visible clamp.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary + text-primary-foreground for the selection check and the progress
  fill, bg-primary/10 for the header glyph, via-primary/25 for the scan sweep,
  text-destructive with bg-destructive/5 and border-destructive/40 for the safety
  panel and filtered slots, bg-muted for every skeleton. No hard-coded colours.
- Selection is a ring (ring-2 ring-primary ring-offset-2) PLUS a check badge, so
  it survives a monochrome theme and a colour-blind reader; focus-visible uses
  the ring token so the two indicators never collapse into one.
- Slots reserve their box from `job.aspectRatio` before a single pixel arrives —
  the skeleton, the blur-up and the final image occupy exactly the same
  rectangle, so nothing on the page jumps when a variation lands.
- Two decorative keyframes (a vertical scan, a sliding indeterminate bar) are
  declared once and hoisted, and every element using them is motion-reduce:hidden
  or motion-reduce:animate-none. Under reduced motion the indeterminate bar
  becomes a static full-width track: the decoration goes, the information stays.
- The root is a <section aria-label> holding ONE persistent sr-only role="status"
  line ("Generating 4 variations, 62 percent", "3 of 4 variations ready.
  Variation 2 selected"), mounted for the whole ready branch so a state CHANGE is
  announced from an element the screen reader is already watching. The safety
  panel and the failure panel are role="alert".
- Long prompts and long model names use `wrap-anywhere` plus `min-w-0`, not
  `break-words`: only the former lowers the min-content width, which is what
  stops a 300-character prompt from making the card wider than its column.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Sheet shape: `columns` (1…6) is both the grid density and the arrow-key row
  stride — 1 for a single hero render, 2 for the classic 2×2, 4 for a contact
  sheet. `job.aspectRatio` drives every slot's box ("1/1", "16/9", "2/3").
- Blocks you can drop: `showFooter` removes the model / seed / size line;
  omitting `negativePrompt` removes its caption; omitting an action handler
  removes that control; `promptLines` sets how much prompt is visible before the
  clamp (raise it in a prompt-engineering tool, lower it in a chat transcript).
- Motion intensity: the scan sweep and the blur radius are the two knobs worth
  touching — drop the sweep entirely for a calm data surface, keep the blur-up,
  and the reveal still reads. Every animation is already opt-out under reduced
  motion.
- Colour: the accent path is bg-primary (selection, progress, glyph) and the
  refusal path is the destructive token — swap the two token families and the
  card follows your theme without touching structure.
- Ownership: `selectedId` moves the selection into your store; `pending` moves
  the upscale/vary lifecycle into your store; `onDownload` takes over the
  download (signed URLs, a save dialog); `onCancel` / `onRegenerate` /
  `onEditPrompt` decide what stopping, re-running and rewriting mean in your app.

Concepts

  • Progressive reveal — a generation is watched, not awaited. Each slot walks a skeleton → blurred preview under a scan sweep → cross-faded final image, so the composition is readable seconds before the pixels are final. The blur-up frame stays mounted and fades out under the real image; unmounting it on the same tick flashes an empty box between the two.
  • The slot lifecycle is not the job lifecycle — backends hand variations back one at a time, so "two done, one half-drawn, one not started" has to be expressible. Modelling per-slot state is what keeps the grid from blanking until the last pixel lands, and what lets one filtered variation sit next to three usable ones.
  • Percent if known, never invented — a job with a number gets a determinate bar with aria-valuenow; a job without one gets an indeterminate bar with no aria-valuenow at all, because a 0 would be announced as "0 percent" for a job that is halfway done. The same rule governs each slot's badge.
  • Safety block is a state, not an error — the model succeeded at refusing. There is no stack trace, nothing to retry, and no pixels to show even if the payload still carries URLs; the only action that helps is rewriting the prompt. A failed generation is the opposite: a transport or model fault, and Try again is exactly right.
  • Selection is a radiogroup with a roving tab stop — the grid is one Tab stop, arrows both move and select (radio semantics), Up/Down move a whole row, and slots with nothing in them are skipped instead of being focusable dead ends. The ring is doubled by a check badge so the choice survives a monochrome theme.
  • Actions degrade instead of faking — Upscale and Vary appear only with a handler, and Download falls back to a real anchor at the image URL rather than becoming a button that does nothing. They live in an overlay that is a sibling of the radio, since interactive content nested inside a radio is invalid and unreachable by keyboard.
  • Seed is provenance — the footer shows the seed of the SELECTED variation when there is one, formatted without grouping separators and one click from the clipboard, because that number is what lets someone reproduce or re-roll exactly this picture tomorrow.

On This Page