# AI Model Compare — React AI Component

> A blind side-by-side: two answers stream at their own speeds with live token stats, then your vote crowns a winner and reveals both model names.

- Category: [AI Elements](https://pixelpapa.com/components/ai.md)
- Tags: ai, compare, models, vote, streaming
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/ai/ai-model-compare

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/ai-model-compare.json"
```

The file is written to `components/pixelpapa/ai-model-compare.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Equal, RefreshCw, ThumbsUp, Trophy } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";

export type CompareAnswer = { model: string; text: string; /** Simulated tokens per second. */ tps: number };
export type CompareVote = "a" | "b" | "tie";

type ModelCompareProps = {
  prompt: string;
  a: CompareAnswer;
  b: CompareAnswer;
  onVote?: (vote: CompareVote) => void;
  className?: string;
};

function useTokens(text: string, tps: number, runKey: number) {
  const reduce = useReducedMotion();
  const words = text.split(" ");
  const [n, setN] = useState(0);
  useEffect(() => {
    setN(0);
    const id = setInterval(() => setN((c) => (c >= words.length ? (clearInterval(id), c) : c + 1)), 1000 / tps);
    return () => clearInterval(id);
  }, [words.length, tps, runKey]);
  const shown = reduce ? words.length : n;
  return { text: words.slice(0, shown).join(" "), tokens: shown, done: shown >= words.length, seconds: shown / tps };
}

function Column({ slot, answer, stream, vote }: { slot: "a" | "b"; answer: CompareAnswer; stream: ReturnType<typeof useTokens>; vote: CompareVote | null }) {
  const won = vote === slot;
  const lost = vote !== null && vote !== "tie" && !won;
  return (
    <motion.div
      animate={{ opacity: lost ? 0.55 : 1, scale: won ? 1.015 : 1 }}
      transition={{ type: "spring", stiffness: 300, damping: 26 }}
      className={cn("relative flex min-w-0 flex-col rounded-2xl border bg-card p-4 transition-colors duration-300", won ? "border-primary shadow-lg shadow-primary/15" : "border-border")}
    >
      <div className="flex h-6 items-center gap-2">
        <span className={cn("flex size-6 items-center justify-center rounded-md text-xs font-semibold uppercase transition-colors", won ? "bg-primary text-primary-foreground" : "bg-muted text-foreground")}>
          {slot}
        </span>
        <span className="relative h-5 flex-1 overflow-hidden text-sm font-medium text-card-foreground">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span key={vote ? "name" : "blind"} initial={{ y: 16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -16, opacity: 0 }} transition={{ type: "spring", stiffness: 400, damping: 30 }} className="absolute inset-0 truncate">
              {vote ? answer.model : `Model ${slot.toUpperCase()}`}
            </motion.span>
          </AnimatePresence>
        </span>
        {won && (
          <motion.span initial={{ scale: 0, rotate: -30 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 500, damping: 16 }} className="text-amber-500">
            <Trophy className="size-4" />
          </motion.span>
        )}
      </div>
      <p className="mt-3 h-[150px] overflow-hidden text-[13px] leading-relaxed text-card-foreground/90" aria-live="off">
        {stream.text}
        {!stream.done && <span aria-hidden className="ml-0.5 inline-block h-[1em] w-[2px] translate-y-[0.15em] animate-pulse rounded-full bg-primary" />}
      </p>
      <p className="mt-3 flex gap-3 border-t border-border pt-2.5 font-mono text-[11px] text-muted-foreground tabular-nums">
        <span>{stream.tokens} tok</span>
        <span>{stream.seconds.toFixed(1)}s</span>
        <span className="ml-auto">{answer.tps} tok/s</span>
      </p>
    </motion.div>
  );
}

export function ModelCompare({ prompt, a, b, onVote, className }: ModelCompareProps) {
  const [run, setRun] = useState(0);
  const [vote, setVote] = useState<CompareVote | null>(null);
  const sa = useTokens(a.text, a.tps, run);
  const sb = useTokens(b.text, b.tps, run);
  const ready = sa.done && sb.done;

  const choices: { value: CompareVote; label: string; icon: typeof ThumbsUp }[] = [
    { value: "a", label: "A is better", icon: ThumbsUp },
    { value: "tie", label: "Tie", icon: Equal },
    { value: "b", label: "B is better", icon: ThumbsUp },
  ];

  return (
    <div className={cn("w-full", className)}>
      <p className="mb-3 truncate text-sm text-muted-foreground">
        <span className="font-medium text-foreground">Prompt</span> · {prompt}
      </p>
      <div className="grid grid-cols-2 gap-3">
        <Column slot="a" answer={a} stream={sa} vote={vote} />
        <Column slot="b" answer={b} stream={sb} vote={vote} />
      </div>
      <div className="mt-4 flex items-center justify-center gap-2" role="group" aria-label="Vote for the better answer">
        {choices.map((c) => (
          <button
            key={c.value}
            type="button"
            aria-pressed={vote === c.value}
            disabled={!ready || vote !== null}
            onClick={() => (setVote(c.value), onVote?.(c.value))}
            className={cn(
              "inline-flex h-9 items-center gap-1.5 rounded-full border px-3.5 text-sm font-medium outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring active:scale-95",
              vote === c.value
                ? "border-primary bg-primary text-primary-foreground"
                : "border-border bg-card text-card-foreground enabled:hover:-translate-y-0.5 enabled:hover:border-primary/50 disabled:text-muted-foreground disabled:opacity-60",
            )}
          >
            <c.icon className={cn("size-3.5", c.value === "b" && "-scale-x-100")} />
            {c.label}
          </button>
        ))}
        <button
          type="button"
          aria-label="Run the comparison again"
          onClick={() => (setVote(null), setRun((r) => r + 1))}
          className="flex size-9 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
        >
          <RefreshCw className="size-4" />
        </button>
      </div>
      <p className="mt-2 h-4 text-center text-xs text-muted-foreground" aria-live="polite">
        {vote ? "Names revealed. Thanks for voting." : ready ? "Pick the answer you would rather receive." : "Both models are answering…"}
      </p>
    </div>
  );
}

const a: CompareAnswer = {
  model: "Atlas 2",
  tps: 34,
  text: "Start with the nine accounts that left inside 60 days. None of them connected a second integration and seven never invited a teammate, which points at onboarding rather than price. Interview three this week and ask what they expected to happen after the first import.",
};
const b: CompareAnswer = {
  model: "Swift",
  tps: 58,
  text: "October churn was 14 Team accounts. Likely causes: pricing, missing features, or weak onboarding. I suggest a survey to all churned accounts, a discount offer for win-back, and a review of the cancellation flow to capture reasons at the moment of leaving.",
};

export default function AiModelCompareDemo() {
  return <ModelCompare prompt="We lost 14 Team accounts in October. Where do I start?" a={a} b={b} className="max-w-2xl" />;
}
```

## AI prompt

```text
Add the "AI Model Compare" component from PixelPapa UI to this project.

What it is: A blind side-by-side: two answers stream at their own speeds with live token stats, then your vote crowns a winner and reveals both model names.
Category: AI Elements. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/ai-model-compare.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/ai-model-compare.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/ai-model-compare" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More ai elements

- [AI Chat Thread](https://pixelpapa.com/components/ai/ai-chat-thread.md) (Pro): A chat thread where user bubbles spring in from the right and the assistant reply streams in chunks behind a blinking caret, auto-scrolling as it grows.
- [AI Code Block With Copy](https://pixelpapa.com/components/ai/ai-code-block-copy.md) (Pro): A code answer with sliding language tabs, line numbers, highlighted lines that wipe in, and a copy button that morphs into a confirmed tick.
- [AI Prompt Input](https://pixelpapa.com/components/ai/ai-prompt-input.md) (Pro): A chat composer with an auto-growing textarea, removable attachment chips, a model picker popover and a send button that morphs into stop while generating.
- [AI Source Citations](https://pixelpapa.com/components/ai/ai-source-citations.md): An answer with numbered citation markers; hovering or focusing one pops a source card and lights the matching source in the strip below.
- [AI Streaming Markdown](https://pixelpapa.com/components/ai/ai-streaming-markdown.md): Renders a markdown answer as it streams: each word fades up out of a blur, with headings, lists, bold and inline code formatted as they arrive.
- [AI Suggestion Chips](https://pixelpapa.com/components/ai/ai-suggestion-chips.md) (Pro): Prompt suggestions grouped by intent; chips cascade in when you switch group, and picking one types its prompt into the input.
