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.
- Category
- AI
- Components used
- 15
- Files
- 8
- Usage examples
- 3
- Access
- HextaUI Pro (55 blocks)
- Source updated
- Counted in
- UI component usage study
Part of HextaUI Pro, all 55 blocks, $49 one-time, Compare plans
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.
Get HextaUI Pro
One payment unlocks every block, including future ones. See pricing.
Add the Pro registry to components.json
components.json Add your token
Create a token on your account page and put it in
.env.localasHEXTAUI_PRO_TOKEN.Add the block
pnpm dlx shadcn@latest add @hextaui-pro/model-compare
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.
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.
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.
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. |
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 |
|---|---|---|
modelsModels the pickers offer, the same shape as the Models block. Add model picks the first one that isn't in a column. | ModelInfo[] | – |
columnsThe starting columns: { id, model, settings? }. settings is { temperature, maxTokens, system } and defaults to 0.7, 2048 and empty. Two to four columns. | CompareColumn[] | – |
onSendCalled 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. | (request: CompareRequest) => CompareStreams | Promise<CompareStreams> | – |
onStopCalled with the ids of the columns that were stopped. Their signals are already aborted. | (columns: string[]) => void | – |
pricesPrice per million tokens by model id. Models without a price show no cost. | Record<string, { input: number; output: number }> | {} |
onVoteShows 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. | (vote: CompareVote) => void | Promise<void> | – |
blindHides model names, pickers, specs and cost until a vote is cast. | boolean | false |
onBlindChangeShows the Blind toggle in the toolbar. | (blind: boolean) => void | – |
turnsTurns to start with, for example a saved comparison. | CompareTurn[] | – |
onTurnsChangeCalled whenever turns change, including streamed text. Save when every answer has settled. | (turns: CompareTurn[]) => void | – |
onColumnsChangeCalled when a column is added or removed, or its model or settings change. | (columns: CompareColumn[]) => void | – |
readOnlyShows the turns without the composer, pickers, settings, Retry, Remove or voting. | boolean | false |
suggestionsPrompt chips above the composer before the first run. | PromptSuggestion[] | – |
stallAfterMilliseconds without a token before a column says it has stalled. | number | 8000 |
classNameClasses for the root. | string | – |
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 |
|---|---|---|
sourceThe response from your route. | Response | ReadableStream<Uint8Array | string> | – |
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 |
|---|---|---|
errorWhatever the stream threw. | unknown | – |
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 |
|---|---|---|
aThe first answer. | string | – |
bThe second answer. | string | – |
| 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. |
- 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.
Code
8 files, added to components/blocks/model-compare.