AI Model Arena
An arena-style side-by-side blind test React component: two anonymous responses stream in sync, you vote A / B / tie / both bad, then the cards flip to reveal the model names and provider color blocks, crown the winner, and roll the scoreboard numbers.
This is a WebberUI Pro component
The install command below needs a licence to run; without one it returns 401.
Two models' answers to the same prompt sit side by side in two anonymous cards labelled "Model A" (Model A) and "Model B" (Model B). A single character counter reveals both responses in sync with a blinking caret, and the four vote buttons underneath only enable once streaming finishes. After a vote both cards flip on rotateY to reveal the model name and provider color block, the chosen side gets a crown badge and pulses once, and the scoreboard digits roll into place. Pressing "Next" and swapping in the next round restarts everything automatically. Both the revealed state and the scores support controlled and uncontrolled modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add "https://webberui.com/r/ai-model-arena.en.json?t=<install token>"AI Model Arena is a React LLM arena component for blind side-by-side model comparison, built with Tailwind CSS, Motion and lucide-react icons. Two anonymous responses type out next to each other, the model names are revealed with a card flip only after the vote, and a scoreboard can run controlled or uncontrolled.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<AiModelArena/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Internal eval tools where teammates run A/B blind tests on two models or two prompt versions.
- User research pages in an AI product that collect "which answer is better" preference data.
- Model selection showcases that let visitors compare how different models answer the same question.
- Workshops or tutorials demonstrating how models differ on the same prompt.
Installation
npx webberui@latest add "https://webberui.com/r/ai-model-arena.en.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/ai-model-arena.
Usage
import { AiModelArena, type ArenaVote } from "@/components/ui/ai-model-arena";
// Model and provider names are fictional
const round = {
prompt: "Explain compound interest in one sentence.",
left: { model: "Aurora-7", provider: "Northern Lights Lab", response: "Compound interest means interest also earns interest…" },
right: { model: "Sage-Coder", provider: "Sage Intelligence", response: "This period's interest is added to the principal, and next period's interest is calculated on the new total…" },
};
<AiModelArena
prompt={round.prompt}
left={round.left}
right={round.right}
onVote={(choice: ArenaVote) => console.log(choice)} // "left" | "right" | "tie" | "both-bad"
onNext={() => loadNextRound()} // swap in the next prompt and responses; typing restarts on its own
/>To own the scoring yourself (for example, half a point each on a tie), switch to controlled scores:
const [scores, setScores] = React.useState<Record<string, number>>({});
<AiModelArena
{...round}
scores={scores}
onVote={(choice) =>
setScores((prev) => {
const next = { ...prev };
if (choice === "left") next[round.left.model] = (next[round.left.model] ?? 0) + 1;
if (choice === "right") next[round.right.model] = (next[round.right.model] ?? 0) + 1;
if (choice === "tie") {
next[round.left.model] = (next[round.left.model] ?? 0) + 0.5;
next[round.right.model] = (next[round.right.model] ?? 0) + 0.5;
}
return next;
})
}
/>Examples
Load rounds from an API and report votes
Keep the rounds in state and advance with onNext; onVote sends the result to your backend. When the prompt or a response changes, the component restarts typing on its own, so there is no need to remount it with key.
"use client";
import { useEffect, useState } from "react";
import {
AiModelArena,
type ArenaContestant,
type ArenaVote,
} from "@/components/ui/ai-model-arena";
type Round = { id: string; prompt: string; left: ArenaContestant; right: ArenaContestant };
export function EvalPanel() {
const [rounds, setRounds] = useState<Round[]>([]);
const [index, setIndex] = useState(0);
useEffect(() => {
fetch("/api/eval/rounds").then((r) => r.json()).then(setRounds);
}, []);
const round = rounds[index];
if (!round) return <p className="text-sm text-neutral-500">Loading rounds…</p>;
return (
<AiModelArena
prompt={round.prompt}
left={round.left}
right={round.right}
onVote={(choice: ArenaVote) =>
fetch("/api/eval/votes", {
method: "POST",
body: JSON.stringify({ roundId: round.id, choice }),
})
}
onNext={() => setIndex((i) => (i + 1) % rounds.length)}
/>
);
}Custom button and badge text
Pass only the labels fields you want to change; the rest keep their built-in text. When comparing two prompt versions, you can say "Version" instead of "Model".
<AiModelArena
{...round}
labels={{
leftAnonymous: "Version A",
rightAnonymous: "Version B",
leftBetter: "Pick A",
rightBetter: "Pick B",
next: "Next item",
}}
/>Show full responses and hide the scoreboard
When reviewing saved transcripts you may not want the typing effect: streaming={false} shows the full text and enables voting right away, showScores={false} hides the scoreboard, and maxHeight gives the response area more room.
<AiModelArena
{...round}
streaming={false}
showScores={false}
maxHeight={400}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
prompt | string | — | Prompt text, shown in the bubble at the top |
left | ArenaContestant | — | Left contestant (model name and response) |
right | ArenaContestant | — | Right contestant (model name and response) |
streaming | boolean | true | Reveal the responses with a typing effect; false (or a reduce-motion preference) shows them in full immediately |
charsPerTick | number | 2 | Characters revealed per tick (40ms); higher is faster |
onVote | (choice: ArenaVote) => void | — | Called when a vote is cast |
scores | Record<string, number> | — | Controlled scores keyed by model name; the scoreboard lists only the models present here. When omitted the component tallies internally (both contestants registered after the vote, 1 point to the winner) |
showScores | boolean | true | Show the scoreboard |
revealed | boolean | — | Controlled: whether the cards are flipped; when omitted, voting and "Next" toggle it automatically |
defaultRevealed | boolean | false | Initial revealed state in uncontrolled mode |
onRevealedChange | (revealed: boolean) => void | — | Called when the revealed state changes (true after a vote, false on "Next") |
onNext | () => void | — | Called when "Next" is pressed; usually where you swap in the next prompt and responses |
maxHeight | number | string | 240 | Max height of each response area (px or any CSS length); content scrolls inside the column beyond that |
labels | ArenaLabels | — | UI copy; override any subset |
className | string | — | Forwarded to the outermost container |
ArenaContestant
| Field | Type | Default | Description |
|---|---|---|---|
model | string | — | Model name; hidden until the vote, revealed on flip |
provider | string | — | Provider name, shown under the model name after the flip and used to color the block; falls back to the model name |
hue | number | — | Hue (0–360) of the provider color block; derived deterministically from the provider name when omitted |
response | string | — | Full response text; revealed character by character when streaming |
ArenaLabels
| Field | Type | Default | Description |
|---|---|---|---|
leftAnonymous | string | "Model A" (Model A) | Anonymous badge text for the left column |
rightAnonymous | string | "Model B" (Model B) | Anonymous badge text for the right column |
leftBetter | string | "A is better." (A is better) | "Left is better" button text |
rightBetter | string | "B is better." (B is better) | "Right is better" button text |
tie | string | "Tie." (Tie) | "Tie" button and badge text |
bothBad | string | "Neither is good." (Both bad) | "Both bad" button and badge text |
next | string | "Next question" (Next) | "Next" button text |
winner | string | "Winner" (Winner) | Winner badge text |
scoreboard | string | "Scoreboard" (Scoreboard) | Scoreboard heading |
noScores | string | "Start scoring after voting" (Scoring starts after your first vote) | Placeholder shown while the scoreboard has no scores yet |
prompt | string | "Question" (Prompt) | Small caption on the prompt bubble |
The ArenaVote type ("left" \| "right" \| "tie" \| "both-bad") and providerHue(name) are also named exports — the latter turns a provider name into a deterministic 0–360 hue, so you can draw the same provider color blocks elsewhere.
How it works
- Synchronized streaming: both columns share one character counter that advances by
charsPerTickevery 40ms; the shorter response finishes first and drops its caret while the longer one keeps going, and the vote row only enables once both are done. - Reveal on flip only: the model name and provider block on the back of each card are mounted only after the reveal — before the vote the model name does not exist in the DOM, so search, Ctrl+F, and assistive tech cannot read it early.
- Scoring rule: in uncontrolled mode both contestants are registered on the scoreboard once the vote is cast (the loser at 0), and "A is better" / "B is better" adds 1 point to the corresponding model; tie and both-bad add nothing. For a different rule (say, half a point each on a tie) pass controlled
scoresand tally insideonVote. - The scoreboard does not spoil the round: it lists only the models present in
scores, and the current pair only shows up after the vote reveals them — so before voting the scoreboard never gives away which two models are competing. If you pass controlledscoresthat pre-lists every model, keeping that anonymity is up to you. - New round without remounting: whenever
promptor eitherresponsechanges it counts as a new round — typing restarts from zero and the previous vote is cleared. Do not remount the component withkey; that would wipe the internal scores too. - Provider color blocks: the
providername is hashed deterministically into a hue (identical on server and client), so the same provider always gets the same color; passhueto override.
Accessibility
- The root container is
role="group"witharia-labelledbypointing at the prompt; before the reveal the back of each card isaria-hidden, after it the front is, so screen readers only hear the face currently shown - The four vote buttons and "Next" are native
buttonelements (type="button") operable with Tab / Enter / Space, with a visiblefocus-visiblering; vote buttons aredisableduntil streaming completes, the chosen one is marked witharia-pressedafter voting, and focus moves to "Next" automatically so keyboard users are not dropped onto body - An
aria-live="polite"region announces "responses complete" and the reveal (for example "Model A: Aurora-7, Model B: Sage-Coder. A is better."); the scoreboard keeps ansr-onlycopy of each real number while the rolling visual digits arearia-hidden - When the user has "reduce motion" enabled at the system level: no character-by-character typing — responses show in full and voting enables immediately; the flip becomes a crossfade between the two faces; and the caret blink, winner pulse, rolling digits, and rank reordering animation are all switched off
FAQ
Does it work with the Next.js App Router?
Yes. The file starts with "use client", so import it from a Server Component page as usual. The first render matches the server output and the reduced-motion check is applied after mount; provider hues are computed deterministically from the name, so server and browser agree.
Can I connect a real token stream?
The typing effect reveals the complete response you pass in, at a speed set by charsPerTick (one tick every 40ms). Any change to prompt or a response counts as a new round and starts over, so pass each response once it is complete rather than updating it chunk by chunk.
Are votes saved?
No. The component only tallies scores in memory, and they reset on reload. To keep results, send them to your backend in onVote; for custom scoring rules or scores that persist across pages, use the controlled scores prop.
Are model names really hidden before the vote?
Yes. Model names and provider blocks are only mounted into the DOM after the reveal, so neither find-in-page nor a screen reader can see them before voting. In uncontrolled mode, the scoreboard also waits until the vote to list this round's two models.
What are the dependencies?
motion, lucide-react, clsx and tailwind-merge. If you prefer not to use the CLI, copy the source from the Manual tab in Installation.
AI Plan Steps
A React component for agent plan tracking, driven purely by data — steps flow through pending → running → done / failed / skipped with tool-icon chips, nested sub-steps joined by a connector line, a breathing pulse on the active step, a completion ratio and total elapsed time; status changes swap icons via AnimatePresence and new steps slide in from below.
AI Attachment Tray
Attachment tray that mounts above any composer: drop or paste files into thumbnail chips with upload progress rings, retry on failure, type icons, limit hints and drag-to-reorder