# Multi-model Compare

> An animated multi-model compare for React and shadcn, streaming two or three models' replies side by side.

One prompt, two or three models answering side by side: each reply streams through Response while its time to first token, tokens per second, tokens and cost roll. A chart of output tokens over time sits under them, and scrubbing it rewinds every reply to that moment. Once all have finished, the preferred reply's card lifts 2px and the others recede.

- Tier: Eased Pro
- Interaction: Select
- Page: https://eased.dev/components/multi-model-compare
- Installs: motion, @base-ui/react

## Install

Multi-model Compare ships with Eased Pro (https://eased.dev/#pricing). Members add the `@eased-pro` registry with their token (https://eased.dev/account), then run:

```bash
npx shadcn add @eased-pro/multi-model-compare
```

The screen for choosing between models: one prompt, two or three replies streaming side by side with their time to first token, tokens per second, tokens and cost, and a chart of output tokens over time whose crosshair rewinds every reply at once. Built on Response, Line Chart, Number Roll, Text Swap and Button.

## When to use

- A playground or eval tool where someone sends one prompt to several models and decides which reply is better.
- Choosing a default model: speed, length and cost side by side, measured on the same prompt.
- Collecting preference data: the pick is a callback, ready to store as a label.

## When not to use

- One model's reply in a chat: use [Conversation](/components/conversation) with Response.
- Comparing runs that call tools step by step: use [Agent Run Console](/components/agent-run-console).
- Comparing aggregate scores across many prompts: a table of eval results says more than two replies.

## Usage

```tsx example.tsx
import { MultiModelCompare, type CompareModel } from "@/components/eased/multi-model-compare/multi-model-compare";

export function Playground({ prompt, models, save }: { prompt: string; models: CompareModel[]; save: (id: string | null) => void }) {
  return <MultiModelCompare className="h-dvh" prompt={prompt} inputTokens={182} models={models} onWinnerChange={save} />;
}

// One model. Append each chunk as it lands, stamped with ms since the request was sent.
const kestrel: CompareModel = {
  id: "kestrel-2",
  name: "Kestrel 2",
  provider: "Fieldline",
  chunks: [
    { at: 1120, text: "Check these ", tokens: 3 },
    { at: 1161, text: "in order:", tokens: 2 },
  ],
  pricing: { inputPerMTok: 2.5, outputPerMTok: 10 },
};
// When the stream closes: { ...kestrel, endedAt: 2840 }.
```

Every number comes from the chunks: the first chunk's `at` is the time to first token, tokens per second counts from it to the latest chunk, and cost adds the prompt's `inputTokens` to the output at each model's `pricing`. A model is `waiting` until its first chunk, `streaming` after it, `done` once it has an `endedAt` and `failed` with an `error`; set `status: "stopped"` after Stop.

## API reference

### MultiModelCompare

| Prop | Type | Default | Description |
|---|---|---|---|
| `prompt` | `ReactNode` | | What every model was asked, clamped to two lines. |
| `inputTokens` | `number` | `0` | The prompt's tokens, shown beside it and billed to every model. |
| `models` | `CompareModel[]` | | Two or three models, in display order. |
| `winnerId` / `defaultWinnerId` / `onWinnerChange` | `string \| null` / `string \| null` / `(id) => void` | `null` | The preferred reply. Prefer appears once every model has settled; pressing the picked one again clears it. Uncontrolled, a new run clears the pick; a controlled `winnerId` is yours to clear when you restart the models. |
| `interval` | `number` | `250` | The chart's time step in ms. |
| `onStop` / `onRestart` | `() => void` | | Stop while any model streams, Run again once all have settled. |
| `currency` / `locale` | `string` | `"USD"` / `"en-US"` | |
| `className` | `string` | `""` | Appended to the root. Give it a height (`h-dvh`, `flex-1`); the replies scroll inside it. |

### CompareModel

| Field | Type | Description |
|---|---|---|
| `id` / `name` / `provider` | `string` | The series key, the card's title and its muted second line. |
| `chunks` | `CompareChunk[]` | `{ at, text, tokens }` in arrival order; `at` is ms since the request. `text` is markdown. |
| `endedAt` | `number` | Ms since the request when the stream closed. Marks the reply done. |
| `error` | `string` | Marks the reply failed and shows why in place of the text. |
| `status` | `CompareStatus` | Overrides the derived status: `waiting`, `streaming`, `done`, `failed` or `stopped`. |
| `pricing` | `{ inputPerMTok, outputPerMTok }` | Per million tokens, in `currency`. Without it the Cost cell stays empty. |
| `color` | `string` | The card's swatch and chart line. Default its slot, `chartColor(n)`. |

`compareStatus(model)` returns the derived status, for your own controls.

## Layout

Two models share a row from 40rem and three from 56rem; narrower, the cards scroll sideways with snap points, the next one peeking in, and a new pick scrolls its card into view. The hint above the cards moves under the prompt. A card's four numbers sit in a row from 26rem and two by two below it. The chart spans the full width under the cards. When the block is shorter than its content, it scrolls as a page. Nothing scrolls sideways at 390px except the row of cards.

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Moves from Stop or Run again to each card's Prefer, then to the chart. |
| `←` / `→`, `Home` / `End` | On the chart, move the crosshair a step and rewind every reply with it. `Escape` lets go. |
| `Enter` / `Space` | Prefers a reply, or clears the pick on the preferred one. |

## Accessibility

- Each card is a section named by its model; its numbers are a description list, so a cell reads as "Tokens/s, 70". An empty number reads as "None yet".
- Prefer is a toggle button (`aria-pressed`) whose name includes the model: "Prefer Kestrel 2". It stays in place but inert while the replies stream, so focus never lands on it early.
- One live region speaks for the screen: "All 2 replies finished. Pick the one you prefer.", then "Kestrel 2 preferred". The replies themselves are not live, so streaming text is never read out word by word.
- The chart is a slider with the crosshair's value as its text ("At 2.75s: Atlas 3 Pro 41, Kestrel 2 114") and every point in a hidden table. Status is text as well as colour.

## Motion

On mount the prompt, the cards and the chart rise 4px in turn (`stagger.line`, once). While the replies stream each new word sharpens out of a soft blur, the numbers roll on the snappy spring, the status line swaps with y + blur, and the chart grows a step at a time, its lines and scale morphing on `smooth`. Scrubbing glides one crosshair across every line and rewinds each reply to the same moment, with no animation on the text. Picking a reply lifts its card 2px on the snappy spring while a raised shadow fades in behind it and its border firms; the other cards recede to 60% over 200ms. Reduced motion: fades only. Nothing rises, slides or lifts, words appear without the blur, and the pick is told by the border, shadow and fade alone.

## Notes for AI

Pick this when one prompt goes to two or three models and a person decides between the replies. Feed it the raw stream: append each chunk with its `at` (ms since the request) and `tokens`, set `endedAt` when the stream closes, and pass `pricing` per model; the block derives time to first token, speed, totals, cost and the chart from those. Store the pick from `onWinnerChange`.

Do not compute the stats yourself or add another chart, timer or legend beside it: the cards are the legend, and the chart is already scrubbable. Do not wrap the cards in your own Card, and keep it to three models; past that, compare scores in a table.

## Related

- [Conversation](/components/conversation): the thread Response comes from, for a single model's reply.
- [Line Chart](/components/line-chart): the chart under the cards, on its own.
- [Agent Run Console](/components/agent-run-console): a single agent run, watched step by step.
