Installation
npx shadcn@latest add @pixeldosa/variation-gridRequires `cn` at `@/lib/utils`. Render each `preview` yourself: an `img`, a poster frame, a card of text. Set `aspectRatio` to your output's shape so the grid never jumps.
Usage
"use client";
import { VariationGrid } from "@/components/ui/variation-grid";
<VariationGrid
title="A dark bottle on a stone surface"
items={results.map((result) => ({
id: result.id,
label: result.label,
status: result.status,
preview: <img src={result.url} alt="" className="size-full object-cover" />,
}))}
keptIds={kept}
onKeptChange={setKept}
onRegenerateRest={regenerate}
regenerateCost="30 credits"
onUse={insert}
/>
Why it exists
Research on how people use generation tools calls it apple picking: they keep the two results they like and run the rest again. Almost no tool supports it, so people download their favourites, re-prompt, and lose track of which was which — paying for the good ones twice.
Features
- Keeping is a state. Kept results are marked and survive the next regeneration.
- "Regenerate the other 3" replaces only what isn't kept, and says so.
- The price is on the button, before the click. Unpriced iteration is the loudest complaint in this category.
- Failures are per cell. One timed-out result shows its own reason and its own Retry; the others stay usable.
- The grid never jumps. Set
aspectRatioto your output's shape. - Only unfinished cells move. Kept ones hold still.
Props
| Prop | Type | Notes |
|---|---|---|
items | { id, preview?, label?, status?, note? }[] | Required. You render the previews. |
keptIds / onKeptChange | string[] / (ids) => void | Required. Caller-owned, so keeping survives a regeneration. |
onRegenerateRest | (idsToReplace) => void | Receives exactly the ids being replaced. |
regenerateCost | string | Shown beside the button. |
onUse / useLabel | (id) => void / string | Takes one result forward. |
onMore | (id) => void | More like this one. |
onRetry | (id) => void | Retries a single failed cell. |
aspectRatio | string | Defaults to "1 / 1". |
title | string | Defaults to "Results". |
headingLevel | 2 | 3 | 4 | 5 | 6 | Defaults to 3. |
Accessibility
- A labelled
<section>; the count says how many are kept and how many can be replaced. - Keep is a toggle button with
aria-pressedand a label naming the result. - A failed cell states its reason in text, not only in colour.
- Running cells say "Generating…"; their pulse stops under
prefers-reduced-motion.
When not to use
- A single result: use
Generation Placeholderwhile it renders, then your own frame. - A gallery of past work: this belongs to one prompt.
- Text alternatives in a chat: use
Response Versions.
why it's built this way
Engineering Notes
The kept set is caller-owned and passed in, because it has to survive the regeneration that replaces the other cells — holding it internally would lose the user's choices exactly when the component re-renders with new results. `onRegenerateRest` hands back the ids it is replacing rather than a bare signal, so the caller can keep positions stable and never has to diff two arrays to work out what the user meant. A cell that is still running is excluded from the replaceable set, which stops a second click from cancelling work already in flight. Per-cell status rather than a batch status comes from how these pipelines actually fail: partial success is the common case, and a grid that collapses to a single error state throws away results the user already paid for. The regeneration cost is a caller-formatted string on the button because unpriced iteration is the single most common complaint in this category.
how it moves, and when it doesn't
Motion Notes
Only unfinished cells move: a running cell pulses its dot while the rest hold still, so a grid mid-regeneration reads as 'these two are working, the others are yours'. Kept cells change by border weight rather than by animation, since keeping is the user's turn and a celebratory motion there would be noise. Under `prefers-reduced-motion` the pulse stops and the running state is carried by its label.