PixelDosa UI

Generation Placeholder

A layout-reserving placeholder for AI-generated media that encodes real generation progress as a resolving dot field, then cross-fades into the finished artifact.

Queued

Queued

Queued

Generating audio

Installation

bash
npx shadcn@latest add @pixeldosa/generation-placeholder

Requires `cn` at `@/lib/utils` and the `pd-fade-in` keyframe supplied by @pixeldosa/pixeldosa-theme's css block. `aspectRatio` is deliberately required rather than optional — it is the prop that prevents a layout shift when the artifact arrives, which is the whole reason this is a container component.

Usage

"use client";

import { GenerationPlaceholder } from "@/components/ui/generation-placeholder";

export function GeneratedImage({ job }: { job: Job }) {
  return (
    <GenerationPlaceholder
      status={job.status}
      progress={job.step / job.totalSteps}
      aspectRatio="16 / 9"
      label="Generating image"
      onCancel={() => job.abort()}
    >
      <img src={job.url} alt={job.prompt} />
    </GenerationPlaceholder>
  );
}

The component is a container, not a sibling that gets swapped out — children render only once status is done. That, plus the required aspectRatio, is what keeps the layout still at the exact moment the artifact appears.

Features

  • Progress is spatial, not temporal. Every cell holds a stable pseudo-random threshold and resolves once progress passes it — ordered-dither logic borrowed from halftone rendering. The pattern is the information, so it stays readable with every animation removed.
  • Never a fabricated percentage. progress is optional with no synthetic fallback. Omit it and you get an honest indeterminate wave rather than a number creeping toward 90%. A percentage appears only when your backend actually measured one.
  • aspectRatio is required, deliberately. A media placeholder that resizes when the artifact arrives causes a layout shift precisely where the user is looking.
  • processing is its own state. Upscaling and encoding are neither generating nor done, so a user is never told "done" while work is still running.
  • Four forms, one threshold engine. field (dot density), mosaic (coarse→fine resolution — the closest to what generation actually does), sweep (a soft develop frontier), and bars for audio, where a rectangle would misrepresent the medium.
  • Cancel stays visible through every active state, never hidden behind a hover.
  • Failure stops visibly. It takes the whole surface with an icon, a plain-language cause, and a retry — rather than dimming the field and reddening a caption, which reads as "still working, but sad".
  • The indeterminate wave is a CSS animation, not a frame loop. Hundreds of cells animate on the compositor with a per-cell delay; React never re-renders.

Variants

A measured generation at 41% — the frontier reads as a soft edge because each cell crosses its own threshold rather than a shared one.

Generating image

41%

Queued

Accepted, not started. Sparse and at rest, so "waiting in line" doesn't read as "working".

Queued

Indeterminate

No progress supplied — a travelling wave rather than an invented number. This is the honest default when the backend doesn't report steps.

Generating

Mosaic

Blocks subdivide coarse → fine as progress climbs. This is what an image actually does while it generates — it gains resolution, not area — which is why it communicates "nearly there" more honestly than a bar. Resolution steps in doublings rather than easing, because a smoothly shrinking block reads as a zoom instead of a refinement.

Generating image

55%

Sweep

A develop frontier with a soft gradient edge — the closest of the four to the gradient wash people recognise from Gemini-style generation. The frontier sits exactly at progress, so the visual and the number can never disagree.

Generating image

41%

Audio

form="bars" for a medium that isn't a rectangle. Same threshold engine, different geometry.

Generating audio

60%

Failed

Failure takes the whole surface rather than dimming the field and turning a caption red — a stopped state should visibly stop. The field stays faintly behind it, showing how far it got, and onRetry gives the message somewhere to go.

Couldn't generate this image. The model timed out.

Examples

Driving it from a real job. Note that progress is passed only while generating — during processing there is nothing measured to report, so it's omitted rather than frozen at 100%:

<GenerationPlaceholder
  status={job.status}
  progress={job.status === "generating" ? job.step / job.totalSteps : undefined}
  aspectRatio="1 / 1"
  label={job.status === "processing" ? "Upscaling" : "Generating image"}
  onCancel={job.abort}
>
  <img src={job.url} alt={job.prompt} />
</GenerationPlaceholder>

A grid of variations, each with its own job — density="sm" keeps the field legible at a small size:

<div className="grid grid-cols-4 gap-3">
  {jobs.map((job) => (
    <GenerationPlaceholder
      key={job.id}
      status={job.status}
      progress={job.progress}
      aspectRatio="1 / 1"
      density="sm"
      label={`Variation ${job.index}`}
    >
      <img src={job.url} alt="" />
    </GenerationPlaceholder>
  ))}
</div>

Video, where the measurable quantity is encoded frames rather than diffusion steps:

<GenerationPlaceholder
  status="processing"
  aspectRatio="16 / 9"
  label={`Encoding · frame ${frame} of ${total}`}
>
  <video src={url} controls />
</GenerationPlaceholder>

Props

PropTypeDefaultDescription
status"queued" | "generating" | "processing" | "done" | "failed"—Required.
progressnumber—0–1. Only supply it when it was actually measured; omit for indeterminate.
aspectRatiostring—Required. Reserves the artifact's final bounds, e.g. "16 / 9".
labelstring—Required. Both the visible and the announced status text.
form"field" | "mosaic" | "sweep" | "bars""field"field dot density, mosaic coarse→fine resolution, sweep a soft develop frontier, bars for audio.
density"sm" | "default" | "lg""default"Cell count. sm for grid cells, lg for hero surfaces.
onCancel() => void—Shown during generating and processing.
onRetry() => void—Recovery action on failed. Without it the failure explains but can't be acted on.
childrenReactNode—The artifact. Rendered only when status is done.

All other props forward to the wrapping <div>.

Accessibility

  • The dot field is aria-hidden — supplementary, never the only signal. The label plus the percentage is the real status, in an aria-live="polite" region.
  • The percentage uses tabular-nums, so the line doesn't shift as it climbs.
  • Cancel is a real keyboard-reachable <button> with a hit area extended to ~44px while its visible size stays compact.
  • Under prefers-reduced-motion every transition is dropped and the indeterminate loop never starts. Cells hold a static pattern — and because progress was already encoded spatially, nothing is lost. This is the rare case where the reduced-motion state carries exactly the same information as the animated one.

When not to use

Streaming text is Progressive Reveal — it appends whole items, where this refines in place. Agent activity with no artifact attached is Agent Presence (planned). And this is not a generic content skeleton: it exists precisely because media generation progress is measurable, and a skeleton throws that information away.

why it's built this way

Engineering Notes

The load-bearing decision is that progress is encoded spatially, not temporally. Each cell derives a stable pseudo-random threshold from its index (a deterministic sine hash, so the pattern never reshuffles between renders — a reshuffling field reads as noise rather than a resolve), and a cell resolves once `progress` passes its threshold. That is ordered-dither logic borrowed from halftone rendering: it produces an organic dissolve that is nonetheless an exact function of real progress, rather than a decorative animation that merely implies work. The payoff is the reduced-motion story, which is unusually strong for an animated component: because the information lives in the pattern rather than the movement, `prefers-reduced-motion` removes every transition and the progress stays completely legible — no fallback had to be invented. `progress` is optional with no synthetic fallback, deliberately: media generation genuinely reports steps (diffusion iterations, encoded frames) where text streaming does not, so a percentage here is measured rather than invented — the system's rule is not 'never show a number' but 'never invent precision you don't have', which is the same rule that makes Confidence Meter refuse one. `aspectRatio` is required because a media placeholder that resizes on completion causes a layout shift at exactly the moment the user's attention is on it. `processing` exists as a state separate from `generating` so a user is never told 'done' while an upscale or encode is still running. The requestAnimationFrame loop that drives indeterminate mode is not started at all when the component is determinate or motion is reduced, since an animation frame loop nobody can see is pure battery cost.

how it moves, and when it doesn't

Motion Notes

Cells transition `transform: scale` at `--pd-duration-base` with `--pd-ease-standard`; because each cell's threshold differs, the resolve reads as a wave without any explicit stagger orchestration — the stagger is a free consequence of the threshold distribution. Indeterminate mode travels a phase offset across the same thresholds on a 2s loop, the single continuous animation in the component, and it maps to real ongoing work rather than decorating a wait. Completion is a cross-fade, never a pop: the artifact fades in over `--pd-duration-base` with `--pd-ease-decelerate` while the field is unmounted, so the two states morph rather than cut. Only `transform` and `opacity` animate, so the whole field composites on the GPU regardless of density. Under `prefers-reduced-motion` every transition is dropped and the wave loop never starts; cells snap to their correct scale and hold a static pattern, which costs nothing because progress was already spatial — the reduced-motion state is the same information without the movement, which is the fallback contract rather than a removal.