compare
Response Compare
A side-by-side pair of response panels with independent regenerate and a single preferred pick.
How does photosynthesis work?
Photosynthesis is the process plants use to convert light energy into chemical energy. Chlorophyll in the leaves absorbs sunlight, which drives a reaction that turns water and carbon dioxide into glucose and oxygen. The glucose fuels the plant's growth, and the oxygen is released as a byproduct.
Plants make their own food through photosynthesis. Using sunlight captured by chlorophyll, they combine CO2 from the air with water drawn up through their roots. This produces glucose for energy and releases oxygen into the atmosphere as a waste product.
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { Check, Copy, RotateCcw, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CompareResponse {
id: string;
label: string;
content: string;
}
export interface ResponseCompareProps {
prompt?: string;
responses: [CompareResponse, CompareResponse];
onPick?: (id: string) => void;
onRegenerate?: (id: string) => void;
className?: string;
}
export function ResponseCompare({ prompt, responses, onPick, onRegenerate, className }: ResponseCompareProps) {
const [pickedId, setPickedId] = React.useState<string | null>(null);
function handlePick(id: string) {
setPickedId(id);
onPick?.(id);
}
function handleRegenerate(id: string) {
setPickedId((current) => (current === id ? null : current));
onRegenerate?.(id);
}
return (
<div className={cn("w-full", className)}>
{prompt && <p className="mb-3 text-sm text-muted-foreground">{prompt}</p>}
<div role="radiogroup" aria-label="Preferred response" className="grid grid-cols-1 gap-3 md:grid-cols-2">
{responses.map((response) => (
<ResponsePanel
key={response.id}
response={response}
picked={pickedId === response.id}
dimmed={pickedId !== null && pickedId !== response.id}
onPick={() => handlePick(response.id)}
onRegenerate={() => handleRegenerate(response.id)}
/>
))}
</div>
</div>
);
}
function ResponsePanel({
response,
picked,
dimmed,
onPick,
onRegenerate,
}: {
response: CompareResponse;
picked: boolean;
dimmed: boolean;
onPick: () => void;
onRegenerate: () => void;
}) {
const [copied, setCopied] = React.useState(false);
async function handleCopy() {
await navigator.clipboard.writeText(response.content);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
return (
<div
role="radio"
aria-checked={picked}
aria-label={response.label}
tabIndex={0}
onClick={onPick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPick();
}
}}
className={cn(
"flex h-80 cursor-pointer flex-col overflow-hidden rounded-2xl border bg-card transition-opacity outline-none",
picked ? "border-primary ring-1 ring-primary" : "border-border hover:border-foreground/20",
dimmed && "opacity-60",
"focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<div className="flex items-center justify-between gap-2 border-b px-4 py-2.5">
<span className="text-xs font-medium">{response.label}</span>
{picked && (
<motion.span
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Sparkles className="size-3" />
Preferred
</motion.span>
)}
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">{response.content}</p>
</div>
<div className="flex items-center justify-between gap-2 border-t px-3 py-2">
<div className="flex items-center gap-0.5 text-muted-foreground">
<IconButton
label="Copy"
onClick={(e) => {
e.stopPropagation();
handleCopy();
}}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</IconButton>
<IconButton
label={`Regenerate ${response.label}`}
onClick={(e) => {
e.stopPropagation();
onRegenerate();
}}
>
<RotateCcw className="size-3.5" />
</IconButton>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPick();
}}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
picked ? "border-primary bg-primary text-primary-foreground" : "hover:bg-accent"
)}
>
{picked ? "Chosen" : "Choose this"}
</button>
</div>
</div>
);
}
function IconButton({
label,
onClick,
children,
}: {
label: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
>
{children}
</button>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Response Compare
## Summary
A side-by-side pair of full response panels answering the same prompt, letting the person read both in full and pick the one they prefer. It turns "regenerate and hope" into a direct A/B choice, and is the natural expansion of thumbs up/down when there are two concrete candidates to weigh instead of one.
## When to use
- Comparing two model outputs (different models, prompts, temperatures, or system prompts) for the same input, where quality is genuinely close and worth a human call.
- Eval/preference-collection tooling that needs an explicit, recorded choice between two candidates rather than a vague quality signal.
- Regeneration flows where showing the new answer next to the old one (instead of replacing it) helps the person decide which to keep.
## When not to use
- When one answer is obviously wrong (factual error, refusal, empty output) — surface an error state or a single corrected answer instead of asking someone to compare against garbage.
- More than two candidates at once. Side-by-side reading breaks down past two full-length responses; use a ranked list or a carousel instead.
- Low-stakes, low-effort answers (a one-line lookup, a short factual reply) where the ceremony of comparing outweighs the value of choosing.
## Anatomy
- Optional shared prompt header showing the question both responses are answering.
- Two response panels in a row (stacking vertically on narrow viewports): a label (model/version name or "Response A"/"Response B"), the response body, and a footer action row (Copy, Regenerate, "Choose this").
- A selection state: the chosen panel gets a visibly distinct border/highlight and a "Preferred" badge; the other panel recedes (reduced emphasis) without disappearing.
## Behavior
- The whole panel is a single click/tap target for selecting it, not just the "Choose this" button — clicking anywhere on a panel's card (its header, body text, or empty space) picks it, the same as clicking the explicit button.
- Copy and Regenerate stay independent actions that don't select the panel — clicking either one acts on that panel's content without also making it the preferred choice, so a person can copy or regenerate one side while still undecided.
- Selecting a panel is a single choice between the two — choosing one always deselects the other; it is not two independent toggles.
- The choice is changeable: picking the other panel (by clicking it anywhere, or its "Choose this" button) after the fact swaps the preferred state, it doesn't require undoing the first choice explicitly.
- Regenerating one panel only replaces that panel's content and clears any existing selection — it never touches the other panel's content.
- Both panels render at equal height with independently scrolling content, so a long response on one side doesn't push the other side's footer out of alignment or off-screen.
- Copy acts on that panel's response text only.
## Content guidelines
- Label panels neutrally ("Response A" / "Response B", or the model name) — avoid labels that imply a quality judgment before the person has read either one.
- Keep the "Choose this" action's label consistent across both panels; don't rephrase it based on which one you'd expect to win.
## Accessibility
- Group the two panels with `role="radiogroup"` and expose each panel itself as `role="radio"` with `aria-checked` and a focusable `tabindex`, since the whole card — not just its "Choose this" button — is the selectable unit and exactly one of two options can be selected.
- Each panel needs a visible hover/focus affordance (e.g. a border change, a focus ring) so the card reads as clickable, not just as a static container with buttons inside it.
- The nested Copy and Regenerate buttons must stop click/keyboard events from also triggering the panel's own selection, so using them never has the side effect of picking that panel.
- Selecting via keyboard must work on the panel itself (Enter or Space while it's focused), not only by tabbing all the way to the "Choose this" button.
- Each panel's response text must be reachable by screen reader in a sensible order — visual left/right placement shouldn't be the only thing separating them; label each region (e.g. `aria-label="Response A"`) so assistive tech announces which one is being read.
- The preferred badge's meaning must not rely on color/border alone — include visible text ("Preferred") or an icon with an accessible name.
- All actions (Copy, Regenerate, Choose this, and selecting the panel itself) must be reachable and operable by keyboard.
## Related patterns
- Chat Bubble with Actions — the single-response equivalent (thumbs up/down, Regenerate) for when there's one reply to judge rather than two to choose between.
- Diff Summary Card — a different kind of side-by-side comparison (before/after a batch of file edits) rather than two independent generations.