AI Interface Components

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.

How to install Pro components →See the plans →

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>"

New project or installation trouble? Start here →

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.

2
240
Active edited usageDefault props
<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

PropTypeDefaultDescription
promptstring—Prompt text, shown in the bubble at the top
leftArenaContestant—Left contestant (model name and response)
rightArenaContestant—Right 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) => void—Called 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
revealedboolean—Controlled: whether the cards are flipped; when omitted, voting and "Next" toggle it automatically
defaultRevealedbooleanfalseInitial 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
maxHeightnumber | string240Max height of each response area (px or any CSS length); content scrolls inside the column beyond that
labelsArenaLabels—UI copy; override any subset
classNamestring—Forwarded to the outermost container

ArenaContestant

FieldTypeDefaultDescription
modelstring—Model name; hidden until the vote, revealed on flip
providerstring—Provider name, shown under the model name after the flip and used to color the block; falls back to the model name
huenumber—Hue (0–360) of the provider color block; derived deterministically from the provider name when omitted
responsestring—Full response text; revealed character by character when streaming

ArenaLabels

FieldTypeDefaultDescription
leftAnonymousstring"Model A" (Model A)Anonymous badge text for the left column
rightAnonymousstring"Model B" (Model B)Anonymous badge text for the right column
leftBetterstring"A is better." (A is better)"Left is better" button text
rightBetterstring"B is better." (B is better)"Right is better" button text
tiestring"Tie." (Tie)"Tie" button and badge text
bothBadstring"Neither is good." (Both bad)"Both bad" button and badge text
nextstring"Next question" (Next)"Next" button text
winnerstring"Winner" (Winner)Winner badge text
scoreboardstring"Scoreboard" (Scoreboard)Scoreboard heading
noScoresstring"Start scoring after voting" (Scoring starts after your first vote)Placeholder shown while the scoreboard has no scores yet
promptstring"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 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 "Model A: Aurora-7, Model B: Sage-Coder. A is better."); 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

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.