WebberUI

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.

How to install Pro components →See the plans →

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.

Loading preview…
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.

2
240
<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

PropTypeDefaultDescription
promptstringPrompt text, shown in the bubble at the top
leftArenaContestantLeft contestant (model name and response)
rightArenaContestantRight contestant (model name and response)
streamingbooleantrueReveal the responses with a typing effect; false (or a reduce-motion preference) shows them in full immediately
charsPerTicknumber2Characters revealed per tick (40ms); higher is faster
onVote(choice: ArenaVote) => voidCalled when a vote is cast
scoresRecord<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)
showScoresbooleantrueShow the scoreboard
revealedbooleanControlled: whether the cards are flipped; when omitted, voting and "Next" toggle it automatically
defaultRevealedbooleanfalseInitial revealed state in uncontrolled mode
onRevealedChange(revealed: boolean) => voidCalled when the revealed state changes (true after a vote, false on "Next")
onNext() => voidCalled when "Next" is pressed; usually where you swap in the next prompt and responses
maxHeightnumber | string240Max height of each response area (px or any CSS length); content scrolls inside the column beyond that
labelsArenaLabelsUI copy; override any subset
classNamestringForwarded to the outermost container

ArenaContestant

FieldTypeDefaultDescription
modelstringModel name; hidden until the vote, revealed on flip
providerstringProvider name, shown under the model name after the flip and used to color the block; falls back to the model name
huenumberHue (0–360) of the provider color block; derived deterministically from the provider name when omitted
responsestringFull response text; revealed character by character when streaming

ArenaLabels

FieldTypeDefaultDescription
leftAnonymousstring"模型 A" (Model A)Anonymous badge text for the left column
rightAnonymousstring"模型 B" (Model B)Anonymous badge text for the right column
leftBetterstring"A 較好" (A is better)"Left is better" button text
rightBetterstring"B 較好" (B is better)"Right is better" button text
tiestring"平手" (Tie)"Tie" button and badge text
bothBadstring"都不好" (Both bad)"Both bad" button and badge text
nextstring"下一題" (Next)"Next" button text
winnerstring"勝出" (Winner)Winner badge text
scoreboardstring"計分板" (Scoreboard)Scoreboard heading
noScoresstring"投票後開始計分" (Scoring starts after your first vote)Placeholder shown while the scoreboard has no scores yet
promptstring"題目" (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 charsPerTick every 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 scores and tally inside onVote.
  • 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 controlled scores that pre-lists every model, keeping that anonymity is up to you.
  • New round without remounting: whenever prompt or either response changes it counts as a new round — typing restarts from zero and the previous vote is cleared. Do not remount the component with key; that would wipe the internal scores too.
  • Provider color blocks: the provider name is hashed deterministically into a hue (identical on server and client), so the same provider always gets the same color; pass hue to override.

Accessibility

  • The root container is role="group" with aria-labelledby pointing at the prompt; before the reveal the back of each card is aria-hidden, after it the front is, so screen readers only hear the face currently shown
  • The four vote buttons and "Next" are native button elements (type="button") operable with Tab / Enter / Space, with a visible focus-visible ring; vote buttons are disabled until streaming completes, the chosen one is marked with aria-pressed after 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 an sr-only copy of each real number while the rolling visual digits are aria-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

On this page