# Model Compare

> Send one prompt to two to four models at once and compare answers, speed and cost side by side, with diffs, blind voting and follow-ups.

Canonical: https://hextaui.com/blocks/model-compare
Last updated: 2026-10-29

## Overview

Picking a model means running the same prompt through a few of them and reading the answers next to each other. Model Compare is that view: one composer at the bottom and two to four columns above it, each with its own model, settings and history. Send once and every column streams at its own pace.

The answers get the space. Columns are neutral and told apart by a letter, not a colour. Each one ends in a single quiet line with its status, time, speed and cost. Hover or focus that line for the full numbers: time to first token, speed with a sparkline of the rhythm, tokens in and out, and cost from the prices you pass in. Usage reported by the stream replaces the estimate. A column that goes quiet for longer than stallAfter says so and offers Stop and Retry.

After each run, one line names the fastest, cheapest and longest answer. Ties aren't crowned. Open Timeline for a ranked waterfall that splits each model's time into waiting for the first token and writing.

Errors stay in their column. A rate limit says when to try again, a context error suggests a bigger model, and Try again runs only that column with the same history. Stop one column from its header or all of them from the composer. Changing a model while it streams stops it first, and the old answer says who wrote it.

Diff compares two answers word by word as plain text. Shared words fade back and the words only one answer has stay at full contrast, underlined, with the share of words in common above. Sync scroll keeps columns at the same relative position, and each column follows its stream until you scroll up.

Voting is arena style: A is better, B is better, Tie or All bad, with an optional reason. In blind mode names, pickers and prices stay hidden until the vote is in, then the names blur in one after another. Votes save optimistically and roll back with a toast if onVote throws.

Follow-ups go to every column with that column's own history. Add a model or remove one at any time. When there isn't room for every column, they become swipeable tabs, one letter each with its status.

## Installation

### Step 1: Get HextaUI Pro

One payment unlocks every block, including future ones. [See pricing](https://hextaui.com/pricing).

### Step 2: Add the Pro registry to components.json

```json title="components.json"
{
  "registries": {
    "@hextaui-pro": {
      "url": "https://hextaui.com/r/pro/{name}.json",
      "headers": {
        "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
      }
    }
  }
}
```

### Step 3: Add your token

Create a token on your [account page](https://hextaui.com/account) and put it in `.env.local` as `HEXTAUI_PRO_TOKEN`.

### Step 4: Add the block

```bash
npx shadcn@latest add @hextaui-pro/model-compare
```

## Usage

### Stream through the AI SDK

onSend gets one request per column with its model, settings, history and an abort signal. Return a stream per column. Here each column goes through DefaultChatTransport to the same /api/chat route useChat uses, so text deltas stream in and the finish metadata reports real token usage.

```tsx
"use client"

import { DefaultChatTransport, type LanguageModelUsage, type UIMessage, type UIMessageChunk } from "ai"

import type { ModelInfo } from "../models/models"
import type { PromptTokenPricing } from "../prompt-input/prompt-controls"
import {
  ModelCompare,
  type CompareColumn,
  type CompareRequest,
  type CompareRequestColumn,
  type CompareStream,
} from "@/components/blocks/model-compare/model-compare"

type ChatMessage = UIMessage<{ usage?: LanguageModelUsage }>

const transport = new DefaultChatTransport<ChatMessage>({ api: "/api/chat" })

function toUIMessages(column: CompareRequestColumn): ChatMessage[] {
  return column.messages.map((message, index) => ({
    id: `${column.column}-${index}`,
    role: message.role,
    parts: [{ type: "text", text: message.content }],
  }))
}

async function* streamColumn(request: CompareRequest, column: CompareRequestColumn): CompareStream {
  const stream = await transport.sendMessages({
    trigger: "submit-message",
    chatId: `${request.turn}-${column.column}`,
    messageId: undefined,
    messages: toUIMessages(column),
    abortSignal: column.signal,
    body: {
      model: column.model,
      system: column.settings.system,
      temperature: column.settings.temperature,
      maxOutputTokens: column.settings.maxTokens,
    },
  })
  const reader = stream.getReader()
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) return
      const chunk: UIMessageChunk = value
      if (chunk.type === "text-delta") yield chunk.delta
      if (chunk.type === "error") throw new Error(chunk.errorText)
      if (chunk.type === "finish" || chunk.type === "message-metadata") {
        const usage = (chunk.messageMetadata as ChatMessage["metadata"])?.usage
        if (usage) yield { type: "usage", inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }
      }
    }
  } finally {
    reader.releaseLock()
  }
}

export function Playground({
  models,
  prices,
  columns,
}: {
  models: ModelInfo[]
  prices: Record<string, PromptTokenPricing>
  columns: CompareColumn[]
}) {
  return (
    <ModelCompare
      models={models}
      prices={prices}
      columns={columns}
      onSend={(request) =>
        Object.fromEntries(request.columns.map((column) => [column.column, streamColumn(request, column)]))
      }
    />
  )
}
```

### Collect blind preference data

Turn on blind and model names stay hidden as Model A and Model B until a vote is in. onVote gets the verdict, the winner's model, every model in the vote and the optional reason. Throw to roll the vote back and show the error. Each new key starts a fresh pair.

```tsx
"use client"

import * as React from "react"

import type { ModelInfo } from "../models/models"
import type { PromptTokenPricing } from "../prompt-input/prompt-controls"
import {
  ModelCompare,
  readTextStream,
  type CompareColumn,
  type CompareRequest,
  type CompareVote,
} from "@/components/blocks/model-compare/model-compare"

function send(request: CompareRequest) {
  return Object.fromEntries(
    request.columns.map(({ column, model, settings, messages, signal }) => [
      column,
      (async function* () {
        const response = await fetch("/api/compare", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ model, settings, messages }),
          signal,
        })
        yield* readTextStream(response)
      })(),
    ])
  )
}

async function saveVote(vote: CompareVote) {
  const response = await fetch("/api/preferences", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      turn: vote.turn,
      verdict: vote.verdict,
      winner: vote.winner?.model ?? null,
      models: vote.columns.map((column) => column.model),
      reason: vote.reason ?? null,
    }),
  })
  if (!response.ok) throw new Error("Your vote wasn’t saved. Try again.")
}

export function BlindEvaluation({
  models,
  prices,
  pairs,
}: {
  models: ModelInfo[]
  prices: Record<string, PromptTokenPricing>
  pairs: [string, string][]
}) {
  const [round, setRound] = React.useState(0)
  const pair = pairs[round % pairs.length]
  const columns: CompareColumn[] = React.useMemo(
    () => [
      { id: "left", model: pair[0] },
      { id: "right", model: pair[1] },
    ],
    [pair]
  )

  return (
    <ModelCompare
      key={round}
      blind
      models={models}
      prices={prices}
      columns={columns}
      onSend={send}
      onVote={async (vote) => {
        await saveVote(vote)
        setTimeout(() => setRound((value) => value + 1), 2500)
      }}
    />
  )
}
```

### Share a comparison

Save columns and turns from onColumnsChange and onTurnsChange once every answer is in. Render them later with readOnly for a permalink: answers, stats, the race and the vote stay visible, and the composer, pickers and voting are gone.

```tsx
"use client"

import * as React from "react"

import type { ModelInfo } from "../models/models"
import type { PromptTokenPricing } from "../prompt-input/prompt-controls"
import { ModelCompare, type CompareColumn, type CompareTurn } from "@/components/blocks/model-compare/model-compare"

type SharedComparison = {
  columns: CompareColumn[]
  turns: CompareTurn[]
}

export function SharedComparison({
  comparison,
  models,
  prices,
}: {
  comparison: SharedComparison
  models: ModelInfo[]
  prices: Record<string, PromptTokenPricing>
}) {
  return (
    <ModelCompare
      readOnly
      models={models}
      prices={prices}
      columns={comparison.columns}
      turns={comparison.turns}
    />
  )
}

export function SaveForSharing({
  models,
  prices,
  columns,
  onSave,
}: {
  models: ModelInfo[]
  prices: Record<string, PromptTokenPricing>
  columns: CompareColumn[]
  onSave: (comparison: SharedComparison) => void
}) {
  const current = React.useRef(columns)
  return (
    <ModelCompare
      models={models}
      prices={prices}
      columns={columns}
      onColumnsChange={(next) => {
        current.current = next
      }}
      onTurnsChange={(turns) => {
        if (turns.every((turn) => Object.values(turn.answers).every((answer) => answer.status === "done"))) {
          onSave({ columns: current.current, turns })
        }
      }}
    />
  )
}
```

## Anatomy

The parts you compose, from the outside in.

| Part | Description |
| --- | --- |
| `ModelCompare` | The whole view: toolbar, columns and a dock with the run summary, vote and composer. |
| `Toolbar` | Blind when onBlindChange is set, Diff (a toggle for two columns, a pair picker for more), Sync scroll, New and Add model. |
| `Column` | A letter, the model picker, and Retry, Settings and Remove on hover or focus. Stop stays visible while it streams. |
| `Column settings` | A popover with temperature, max tokens, system prompt and a switch to keep every column the same. |
| `Stats line` | Status, time, speed and cost in one line. Hover, focus or click it for every number. |
| `Run summary` | The run number and who was fastest, cheapest and longest, with Timeline to open the ranked waterfall. |
| `Vote` | Verdict buttons with their shortcut keys and an optional reason, then the result with Change vote. |
| `Tabs` | On narrow screens, one tab per column with its letter and status. Swipe or use the arrow keys to switch. |
| `Composer` | Prompt Input with suggestions before the first run and Stop while anything streams. |

## API reference

### ModelCompare

Fills its parent's height. Give it a parent with a height, such as a flex child with min-h-0.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `models` | `ModelInfo[]` | – | Models the pickers offer, the same shape as the Models block. Add model picks the first one that isn't in a column. |
| `columns` | `CompareColumn[]` | – | The starting columns: { id, model, settings? }. settings is { temperature, maxTokens, system } and defaults to 0.7, 2048 and empty. Two to four columns. |
| `onSend` | `(request: CompareRequest) => CompareStreams \| Promise<CompareStreams>` | – | Called on send, and on Retry with just that column. request is { prompt, turn, columns }, and each column is { column, model, settings, messages, signal }. Return a map of column id to an async iterable of strings, { type: "text", text } or { type: "usage", inputTokens, outputTokens }. Throw from a stream to show the error in that column. Stop the request when its signal aborts. |
| `onStop` | `(columns: string[]) => void` | – | Called with the ids of the columns that were stopped. Their signals are already aborted. |
| `prices` | `Record<string, { input: number; output: number }>` | `{}` | Price per million tokens by model id. Models without a price show no cost. |
| `onVote` | `(vote: CompareVote) => void \| Promise<void>` | – | Shows the vote after a run where at least two answers finished. vote is { turn, verdict, columns, winner?, reason? }, and verdict is "col:0" to "col:3", "tie" or "bad". Throw to roll the vote back and show the message. |
| `blind` | `boolean` | `false` | Hides model names, pickers, specs and cost until a vote is cast. |
| `onBlindChange` | `(blind: boolean) => void` | – | Shows the Blind toggle in the toolbar. |
| `turns` | `CompareTurn[]` | – | Turns to start with, for example a saved comparison. |
| `onTurnsChange` | `(turns: CompareTurn[]) => void` | – | Called whenever turns change, including streamed text. Save when every answer has settled. |
| `onColumnsChange` | `(columns: CompareColumn[]) => void` | – | Called when a column is added or removed, or its model or settings change. |
| `readOnly` | `boolean` | `false` | Shows the turns without the composer, pickers, settings, Retry, Remove or voting. |
| `suggestions` | `PromptSuggestion[]` | – | Prompt chips above the composer before the first run. |
| `stallAfter` | `number` | `8000` | Milliseconds without a token before a column says it has stalled. |
| `className` | `string` | – | Classes for the root. |

### readTextStream

Turns a fetch Response or a ReadableStream of text into a column stream. A response that isn't ok throws with its status, so rate limits and context errors are labelled.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `Response \| ReadableStream<Uint8Array \| string>` | – | The response from your route. |

### classifyError

How column errors are labelled. status 429 or a rate-limit message is a rate limit, and retryAfter (seconds) is shown. status 413, a context_length code or a context message is a context error. Anything else shows its message.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `error` | `unknown` | – | Whatever the stream threw. |

### wordDiff

The word-level diff behind Diff mode, for your own reports. Returns { a, b, similarity }, where a and b are lists of { text, changed }. Diff mode strips Markdown first, so pass plain text for the same result.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `a` | `string` | – | The first answer. |
| `b` | `string` | – | The second answer. |

## Keyboard

| Key | Action |
| --- | --- |
| `Enter` | In the composer, sends to every column. Shift+Enter adds a line. |
| `Alt` `←` | Moves focus to the previous column. Alt+→ moves to the next one. |
| `←` `→` | On the tabs, switches columns. Home and End go to the first and last. |
| `1` | After a run, votes for A. 2, 3 and 4 vote for the other columns. Works when focus isn't in a text field. |
| `T` | Votes a tie. |
| `X` | Votes that every answer is bad. |
| `Escape` | In the composer while streaming, stops every column. |

## Accessibility

- Each column's answers are a focusable scroll region named by its model, or by its letter in blind mode, so keyboard users can scroll it.
- One polite live region announces when each column finishes, fails or stops, batched so streams finishing together are read once, followed by All answers are in. Votes, reveals, diffs and added or removed columns are announced too.
- Status is never shown by colour alone: every state has an icon and a word, the stats line names its status, and the timeline lists each time and place in text.
- The stats line is a button. Its label reads the status and time, and the full numbers open on hover, focus or click.
- Vote buttons list their shortcut in aria-keyshortcuts, and shortcuts never fire while you type.
- Diff marks are underlined as well as brighter, and the share of words in common is written out.
- On narrow screens the columns are a real tablist with tabpanels, and each tab is named by its model. Swiping and the arrow keys stay in sync.
- With reduced motion, the reveal, the bar growth and the tab scroll don't animate. In forced colours, bars and marks use system colours.

## Built with

The free HextaUI components Model Compare is made from. Each one installs on its own.

- [Bubble](https://hextaui.com/docs/bubble) - Chat message bubbles with variants, grouped corners, reactions and room for interactive content.
- [Button](https://hextaui.com/docs/button) - Buttons in every variant and size, with a built-in loading, success and error flow that skips the spinner for fast requests.
- [Collapsible](https://hextaui.com/docs/collapsible) - A panel that shows and hides with height motion you can reverse mid-way, without jumping the layout.
- [Dropdown menu](https://hextaui.com/docs/dropdown-menu) - A menu of actions and options behind a button, with groups, submenus, checkbox and radio items, and shortcuts.
- [Field](https://hextaui.com/docs/field) - Labels, descriptions and errors wired to their control, with validation states and layouts for forms.
- [Input](https://hextaui.com/docs/input) - A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
- [Kbd](https://hextaui.com/docs/kbd) - Key caps for shortcuts that show the right symbols on every platform, read them out properly and press down with the real keys.
- [Message](https://hextaui.com/docs/message) - A chat message row with an avatar, name, bubbles and status, where new messages rise in from the sender's side.
- [Popover](https://hextaui.com/docs/popover) - A floating panel anchored to a trigger that resizes smoothly with its content and follows the trigger’s direction.
- [Slider](https://hextaui.com/docs/slider) - Pick a value or a range by dragging, tapping the track or using the keys, with jumps that glide and an optional value bubble.
- [Spinner](https://hextaui.com/docs/spinner) - A loading indicator with Apple-style ticks or a breathing ring that can wait before showing and stay long enough not to flicker.
- [Switch](https://hextaui.com/docs/switch) - An on/off toggle you can tap, press or drag, with a thumb that stretches under your finger and an optional pending state for saves.
- [Textarea](https://hextaui.com/docs/textarea) - Multi-line text that grows smoothly with what you write between a minimum and maximum number of rows, with counters, validation and a submit shortcut.
- [Toast](https://hextaui.com/docs/toast) - Brief messages that stack neatly, expand on hover, swipe away, and turn a loading state into success or error in place.
- [Tooltip](https://hextaui.com/docs/tooltip) - A short hint on hover or keyboard focus that opens after a brief rest, switches instantly between neighbours and shows shortcuts.

## Code

8 files, added to components/blocks/model-compare.
