Model arena
ProA blind side by side model comparison in one card: send one prompt to two models, watch both answers stream at the same pace, vote for the better one, then reveal which model wrote each.
- Blind human preference tests between two models on the same prompt, such as an internal bake-off before switching providers.
- Prompt or model evaluation tools where people should judge answers without knowing which model wrote them.
- Showing latency, speed and cost differences between two models live, side by side, on a real prompt.
- Use model-picker to let someone choose one model for their own chat; this block compares answers, it does not set a default.
- Use eval-suite to score many prompts automatically against expected outputs rather than collecting one human vote at a time.
- Use model-router to explain or configure automatic routing between models.
- Use ai-chat for a single ongoing conversation with one model.
Installation
Pro source and install commands unlock with a Pro plan.
Usage
Use it for evals, demos or a feedback page. Pass models and respond to stream real answers per side, and onVote to record the choice; with no props it plays scripted answers.
import { ModelArena, ArenaRunError } from "@/components/arc/blocks/model-arena/model-arena"; // Stream each model through your own endpoint. Yield text as it arrives.async function* run(model, prompt, signal) { const response = await fetch("/api/arena", { method: "POST", body: JSON.stringify({ model: model.id, prompt }), signal }); if (!response.ok) throw new ArenaRunError(response.status === 429 ? "Rate limited. Try again in a moment." : "The model did not respond."); const reader = response.body.getReader(); const decoder = new TextDecoder(); for (;;) { const { done, value } = await reader.read(); if (done) return; yield decoder.decode(value, { stream: true }); }} export function Arena() { return <ModelArena run={run} onVote={vote => saveVote(vote)} />;}ModelArena
A blind side by side comparison in one card. A single line prompt field with Run and three sample chips sends the same prompt to two models, and two slim panes stream their answers at the same pace inside a fixed height. Each pane header shows "Model A" or "Model B" with a small status (Thinking, Writing, Done), the first pane to finish earns a Finished first marker, and a quiet line under each pane rolls first token time, tokens per second and cost on tabular digits. When both answers land, the vote row offers A is better, Tie, B is better and Both bad. Voting reveals both models: the brand mark and name crossfade in where the letter stood while the label's width follows on a spring with no bounce, and the row becomes a one line result with Next round. A Leaderboard button opens a popover with the top five by win rate, which replays the latest vote as a smooth reorder. A Blind switch reveals names at any time, a pane that fails shows its error and a retry, and below 560px the panes become two tabs that keep both statuses in view.
contestantsArenaModel[]arenaModelsThe pool each round draws its blind pair from. An ArenaModel has id, name, optional shortName for tabs, provider, an optional mark component with markColor for mono marks, price { input, output } in US dollars per million tokens, and an optional color for its leaderboard bar (a series token such as var(--series-2)). The default pool is Claude Opus 5.5, GPT-6.1 Sol, Gemini 4 Argon, Grok 4.7, Claude Sonnet 5.5, DeepSeek V4.1 Flash and Mistral Large 4; Anthropic prices are list prices and the others are sample values.promptsArenaPrompt[]arenaPromptsSample prompts shown as chips under the field, each { id, label, text }. The first one prefills the field, a chip fills and runs its prompt, and Next round moves to the next one.run(model: ArenaModel, prompt: string, signal: AbortSignal) => AsyncIterable<ArenaStreamEvent>–Streams one model's answer. Yield text deltas as strings, or objects { text?, inputTokens?, outputTokens? } to report the provider's usage; without usage, output tokens are estimated at four characters per token. Throw to fail the pane; throw an ArenaRunError with { retryable: false } to hide the retry. Stop when the signal aborts. Without run, the arena streams scripted sample answers at simulated speeds and says so in its live region.onVote(vote: ArenaVote) => void–Called once per vote with { round, prompt, models (model A, model B), choice ("a" | "b" | "tie" | "bad"), winner, metrics }. Each metrics entry carries ttftMs, tokensPerSecond, totalMs, outputTokens and costUsd as measured from the stream.defaultBlindbooleantrueHides model names and marks until a vote. The Blind switch flips it at any time.autoStartbooleantrue without run, false with runRuns the first round once the card scrolls into view.initialStandingsRecord<string, { wins: number; ties: number; losses: number }>–Starting records per model id, for a leaderboard loaded from your own store. Without it the leaderboard counts this session's votes from zero.classNamestring–Class on the root element.- Tab
- Moves through Leaderboard, the Blind switch, the prompt field, Run, the sample chips, each answer (a scrollable region), Retry where shown, and the vote buttons or Next round.
- Enter
- In the prompt field, runs the prompt. While the models are answering, Run becomes Stop.
- Command EnterorCtrl Enter
- Runs the prompt from the field, Run or a chip.
- Escape
- Stops a run in progress from the prompt row, and closes the leaderboard popover, returning focus to its button.
- Arrow leftorArrow right, Home, End
- On the narrow layout's answer tabs, moves between model A and model B.
- Arrow up, Page up, Home
- Inside a focused answer, scrolls back and stops it following the stream; scrolling to the end follows again.
- Enter, Space
- On a vote button, records the vote and moves focus to Next round, which starts the next blind round and returns focus to Stop.
- Each pane is a labelled region ("Model A", or "Model A, Claude Opus 5.5" once revealed). Its answer is a focusable scroll region marked aria-busy while it streams.
- In blind mode no model name, mark or title reaches the page or the accessibility tree until the vote.
- Every status pairs an icon with a word: Thinking, Writing, Done, Finished first, Stopped, Failed. Color only reinforces it.
- A polite status region narrates the round: that a run started (and that the preview is simulated), which model finished first, that voting is open, the vote result with both real models, and a leaderboard reset.
- On narrow containers the panes use tab semantics: a tablist with aria-selected tabs, each controlling a tabpanel; the hidden panel leaves the tab order.
- The vote buttons form a labelled group. Leaderboard rows read their rank and win rate as text, the bars are hidden from assistive technology, and the Leaderboard button's name says when a vote has updated it.
- Run carries aria-keyshortcuts for its shortcut. Pointer focus draws no ring; keyboard focus shows Arc's shared focus ring, inset inside scroll regions.
- Answers stream in chunks of whole words every 30 to 40ms, each fading in over 160ms with no blur or travel, at the same pace in both panes; settled text never re-renders, and each answer follows its stream until the reader scrolls up.
- The reveal is one minimal crossfade: "Model A" fades out in 100ms while the brand mark and name fade in over 160ms from 2px below, and the label's measured width follows on a spring with no bounce. Status words and tab labels swap the same way; no character morph, blur or per letter motion anywhere.
- Working statuses shimmer with Text shimmer, and each status word mounts with its own shimmer so a change is a crossfade, never a morph inside the label.
- First token time counts live while the model thinks; speed and cost are sampled twice a second while it writes and roll on Animated counter digits, so each roll settles before the next.
- The vote row crossfades between waiting, voting and result while its height follows on the smooth spring. Run and Stop share one cell, so the button never changes width.
- Opening the leaderboard after a vote replays it: rows start at their earlier ranks and glide to the new ones on a critically damped spring while win rates roll and bars scale in each model's series color; ranks stay put while rows move. After the first vote, the bars grow from empty.
- Narrow tabs slide their highlight on a 220ms tween with no bounce, and the chosen pane fades in.
- Reduced motion keeps every state, label and announcement but shows chunks without fading, swaps labels instantly, reorders rows in place and stops the spinner and shimmer.
- The card is up to 800px wide. From 560px the panes sit side by side; below that they become two tabs, each showing the model's name and status, so both statuses stay visible while you read one answer.
- Each answer scrolls inside a fixed height, so the card never grows while text streams. Code blocks scroll sideways inside the pane.
- Below 480px the Leaderboard button collapses to its icon with an accessible name, the shortcut hint hides, sample chips scroll sideways in one line, and the vote buttons form a two by two grid.
- Streamed chunks are memoized spans that never re-render once shown, so long answers stay cheap.
- The first token clock ticks on animation frames only while a model thinks, and speed and cost are sampled twice a second only while it writes.
- Every stream has its own AbortController: Stop, a new prompt, Next round and Retry abort the work they replace, and the arena aborts everything on unmount.
- The leaderboard rows mount only while the popover is open. The simulation runs on requestAnimationFrame and pauses with the tab.
Notes for AI
Give your coding assistant the Markdown reference instead of screenshots.
- Pass run to stream real models; it receives the ArenaModel, the prompt text and an AbortSignal. Timing (first token, speed) is measured from your stream on the client, so yield as soon as text arrives rather than buffering.
- Report the provider's usage by yielding { inputTokens, outputTokens } (with or without text) for exact cost; otherwise tokens are estimated from characters and cost uses the model's price.
- Throw from run to fail one pane; the other keeps streaming. ArenaRunError(message, { retryable: false }) hides the retry button.
- Persist votes in onVote. To show standings from earlier sessions, pass initialStandings keyed by model id; a tie counts as half a win and Both bad counts as a loss for each model.
- Give each model a color from --series-1 to --series-4 so providers read as families on the leaderboard; the default pool colors by provider.
- In blind mode cost and speed can hint at a model's identity; leave them for honest measurement, or pass the same price for every contestant if identity must stay fully hidden.
- Model names, marks and non-Anthropic prices in the default pool are sample content, not endorsements or published benchmarks.
The full library index for assistants is at /llms.txt.