AI Model Arena (React)
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
Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.
Two models' answers to the same prompt sit side by side in two anonymous cards labelled "模型 A" (Model A) and "模型 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.
npx shadcn@latest add "https://webberui.com/r/ai-model-arena.json?t=<install token>"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 />
Installation
npx shadcn@latest add "https://webberui.com/r/ai-model-arena.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: "用一句話解釋什麼是「複利」。",
left: { model: "Aurora-7", provider: "北極光實驗室", response: "複利就是利息也會生利息……" },
right: { model: "Sage-Coder", provider: "鼠尾草智能", response: "本期利息併入本金,下一期再計息……" },
};
<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;
})
}
/>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 | "模型 A" (Model A) | Anonymous badge text for the left column |
rightAnonymous | string | "模型 B" (Model B) | Anonymous badge text for the right column |
leftBetter | string | "A 較好" (A is better) | "Left is better" button text |
rightBetter | string | "B 較好" (B is better) | "Right is better" button text |
tie | string | "平手" (Tie) | "Tie" button and badge text |
bothBad | string | "都不好" (Both bad) | "Both bad" button and badge text |
next | string | "下一題" (Next) | "Next" button text |
winner | string | "勝出" (Winner) | Winner badge text |
scoreboard | string | "計分板" (Scoreboard) | Scoreboard heading |
noScores | string | "投票後開始計分" (Scoring starts after your first vote) | Placeholder shown while the scoreboard has no scores yet |
prompt | string | "題目" (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 "模型 A:Aurora-7,模型 B:Sage-Coder。A 較好"); 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
AI Plan Steps (React)
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