{"slug":"response-compare","category":"compare","job":"trust","added":"2026-09-12T20:41Z","title":"Response Compare","description":"A side-by-side pair of response panels with independent regenerate and a single preferred pick.","url":"https://ai-patterns.app/patterns/compare/response-compare","markdown":"https://ai-patterns.app/patterns/compare/response-compare.md","json":"https://ai-patterns.app/api/patterns/compare/response-compare","seenIn":[{"team":"lmarena","source":"https://lmarena.ai/faq"}],"spec":"# Response Compare\n\n## Summary\nA 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.\n\n## When to use\n- 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.\n- Eval/preference-collection tooling that needs an explicit, recorded choice between two candidates rather than a vague quality signal.\n- Regeneration flows where showing the new answer next to the old one (instead of replacing it) helps the person decide which to keep.\n\n## When not to use\n- 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.\n- 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.\n- Low-stakes, low-effort answers (a one-line lookup, a short factual reply) where the ceremony of comparing outweighs the value of choosing.\n\n## Anatomy\n- Optional shared prompt header showing the question both responses are answering.\n- 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\").\n- A selection state: the chosen panel gets a visibly distinct border/highlight and a \"Preferred\" badge; the other panel recedes (reduced emphasis) without disappearing.\n\n## Behavior\n- 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.\n- 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.\n- Selecting a panel is a single choice between the two — choosing one always deselects the other; it is not two independent toggles.\n- 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.\n- Regenerating one panel only replaces that panel's content and clears any existing selection — it never touches the other panel's content.\n- 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.\n- Copy acts on that panel's response text only.\n\n## Content guidelines\n- 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.\n- Keep the \"Choose this\" action's label consistent across both panels; don't rephrase it based on which one you'd expect to win.\n\n## Accessibility\n- 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.\n- 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.\n- 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.\n- 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.\n- 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.\n- The preferred badge's meaning must not rely on color/border alone — include visible text (\"Preferred\") or an icon with an accessible name.\n- All actions (Copy, Regenerate, Choose this, and selecting the panel itself) must be reachable and operable by keyboard.\n\n## Related patterns\n- **Chat Bubble with Actions** — Use it instead when there is one reply to rate or regenerate rather than two to choose between.\n- **Diff Summary Card** — Use it instead when the comparison is before and after a batch of file edits rather than two independent generations.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\nimport { Check, Copy, RotateCcw, Sparkles } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface CompareResponse {\n  id: string;\n  label: string;\n  content: string;\n}\n\nexport interface ResponseCompareProps {\n  prompt?: string;\n  responses: [CompareResponse, CompareResponse];\n  onPick?: (id: string) => void;\n  onRegenerate?: (id: string) => void;\n  className?: string;\n}\n\nexport function ResponseCompare({ prompt, responses, onPick, onRegenerate, className }: ResponseCompareProps) {\n  const [pickedId, setPickedId] = React.useState<string | null>(null);\n\n  function handlePick(id: string) {\n    setPickedId(id);\n    onPick?.(id);\n  }\n\n  function handleRegenerate(id: string) {\n    setPickedId((current) => (current === id ? null : current));\n    onRegenerate?.(id);\n  }\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      {prompt && <p className=\"mb-3 text-sm text-muted-foreground\">{prompt}</p>}\n      <div role=\"radiogroup\" aria-label=\"Preferred response\" className=\"grid grid-cols-1 gap-3 md:grid-cols-2\">\n        {responses.map((response) => (\n          <ResponsePanel\n            key={response.id}\n            response={response}\n            picked={pickedId === response.id}\n            dimmed={pickedId !== null && pickedId !== response.id}\n            onPick={() => handlePick(response.id)}\n            onRegenerate={() => handleRegenerate(response.id)}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction ResponsePanel({\n  response,\n  picked,\n  dimmed,\n  onPick,\n  onRegenerate,\n}: {\n  response: CompareResponse;\n  picked: boolean;\n  dimmed: boolean;\n  onPick: () => void;\n  onRegenerate: () => void;\n}) {\n  const [copied, setCopied] = React.useState(false);\n\n  async function handleCopy() {\n    await navigator.clipboard.writeText(response.content);\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1500);\n  }\n\n  return (\n    <div\n      role=\"radio\"\n      aria-checked={picked}\n      aria-label={response.label}\n      tabIndex={0}\n      onClick={onPick}\n      onKeyDown={(e) => {\n        if (e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          onPick();\n        }\n      }}\n      className={cn(\n        \"flex h-80 cursor-pointer flex-col overflow-hidden rounded-2xl border bg-card transition-opacity outline-none\",\n        picked ? \"border-primary ring-1 ring-primary\" : \"border-border hover:border-foreground/20\",\n        dimmed && \"opacity-60\",\n        \"focus-visible:ring-2 focus-visible:ring-ring\"\n      )}\n    >\n      <div className=\"flex items-center justify-between gap-2 border-b px-4 py-2.5\">\n        <span className=\"text-xs font-medium\">{response.label}</span>\n        {picked && (\n          <motion.span\n            initial={{ opacity: 0, scale: 0.9 }}\n            animate={{ opacity: 1, scale: 1 }}\n            className=\"flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary\"\n          >\n            <Sparkles className=\"size-3\" />\n            Preferred\n          </motion.span>\n        )}\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto px-4 py-3\">\n        <p className=\"whitespace-pre-wrap text-sm leading-relaxed text-foreground/90\">{response.content}</p>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-2 border-t px-3 py-2\">\n        <div className=\"flex items-center gap-0.5 text-muted-foreground\">\n          <IconButton\n            label=\"Copy\"\n            onClick={(e) => {\n              e.stopPropagation();\n              handleCopy();\n            }}\n          >\n            {copied ? <Check className=\"size-3.5\" /> : <Copy className=\"size-3.5\" />}\n          </IconButton>\n          <IconButton\n            label={`Regenerate ${response.label}`}\n            onClick={(e) => {\n              e.stopPropagation();\n              onRegenerate();\n            }}\n          >\n            <RotateCcw className=\"size-3.5\" />\n          </IconButton>\n        </div>\n        <button\n          type=\"button\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onPick();\n          }}\n          className={cn(\n            \"rounded-full border px-3 py-1 text-xs font-medium transition-colors\",\n            picked ? \"border-primary bg-primary text-primary-foreground\" : \"hover:bg-accent\"\n          )}\n        >\n          {picked ? \"Chosen\" : \"Choose this\"}\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction IconButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string;\n  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      aria-label={label}\n      className=\"rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground\"\n    >\n      {children}\n    </button>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}