KQ-1296 · SIDE/BY/SIDE
Side by Side
Two models answer one prompt in parallel columns, each streaming at its own pace — its time to first token, then its token rate, scaled by speed — with a live clock, a progress hairline and a Done stamp that lands on flick. Once both finish, a highlighter sweeps across what each says that the other does not, in reading order within 600ms, and the stats tally beneath them with bars that grow on glide. The vote puck drags 1:1, rubber-bands past the ends and settles on snap at the stop the throw was heading for, the preferred column takes its hue as a badge lands on recoil, and the reason you pick moves into the summary on glide; the stops are a radio group on the arrow keys, and on a phone the answers become two tabs.
streaming both answers · at 1× speed
Install
One command — the source lands in your repo. Or copy it from the Code tab.
$ pnpm dlx shadcn@latest add @kinetiq/side-by-side
Props
- speed
- number
- default 1
- Playback speed of both streams as a multiple of real time. The clocks and stats still read in the models' real time.
- diff
- "phrases" | "sentences" | "off"
- default "phrases"
- What is marked once both finish: runs of two or more words the other answer never uses, whole sentences with under half their words in common, or nothing.
- vote
- "pick" | "scale"
- default "pick"
- The vote bar: three stops (A better, tie, B better), or five with much better at each end.
- prompt
- string
- default defaultComparisonPrompt
- The prompt both models answered, shown above the columns.
- answers
- [ComparisonAnswer, ComparisonAnswer]
- default defaultComparisonAnswers
- The two answers: id, model, text, latencyMs and tokensPerSecond. Set an answer's status to drive its column yourself as text arrives; leave it unset and the column plays the text in at its own pace.
- autoPlay
- boolean
- default true
- Start streaming when the surface first scrolls into view. Off: both answers show complete, with their marks.
- value / defaultValue
- ComparisonVote | null
- default null
- The vote: choice ("a", "b" or "tie"), margin (1 or 2) and an optional reason. Controlled or uncontrolled.
- onValueChange
- (vote: ComparisonVote | null) => void
- default —
- Fires from the drag, key or press that changed the vote or its reason.
- reasons
- string[]
- default —
- Why a winner won, offered after a vote for a side. Defaults to More accurate, Clearer, More concise, Safer advice.
- onRerun
- () => void
- default —
- Run again was pressed: both streams replay from the start and an uncontrolled vote clears.
- onRetry
- (answerId: string) => void
- default —
- Retry was pressed on a column whose answer has status "error".
- onFinish
- (answerId: string, stats: ComparisonStats) => void
- default —
- A column finished playing, with its first-token time, total time, token count and rate.
- status
- "ready" | "loading" | "error"
- default "ready"
- Whether the run has data yet: skeleton columns while loading, a message with Run again on error.
- labels
- { a: string; b: string }
- default { a: "A", b: "B" }
- The two columns' short names, used on their badges, the stats and the vote stops.
- label
- string
- default "Model comparison"
- The surface's accessible name.
- sound
- boolean
- default false
- A tick for each stop the puck crosses and each reason picked; a chime when a vote lands, pitched by side.
- disabled
- boolean
- default false
- Read only: no vote, no reason, no re-run.
- className
- string
- default —
- Classes for the root. It is 560px tall by default; pass a height class to change it.
| Prop | Type | Default | Description |
|---|---|---|---|
| speed | number | 1 | Playback speed of both streams as a multiple of real time. The clocks and stats still read in the models' real time. |
| diff | "phrases" | "sentences" | "off" | "phrases" | What is marked once both finish: runs of two or more words the other answer never uses, whole sentences with under half their words in common, or nothing. |
| vote | "pick" | "scale" | "pick" | The vote bar: three stops (A better, tie, B better), or five with much better at each end. |
| prompt | string | defaultComparisonPrompt | The prompt both models answered, shown above the columns. |
| answers | [ComparisonAnswer, ComparisonAnswer] | defaultComparisonAnswers | The two answers: id, model, text, latencyMs and tokensPerSecond. Set an answer's status to drive its column yourself as text arrives; leave it unset and the column plays the text in at its own pace. |
| autoPlay | boolean | true | Start streaming when the surface first scrolls into view. Off: both answers show complete, with their marks. |
| value / defaultValue | ComparisonVote | null | null | The vote: choice ("a", "b" or "tie"), margin (1 or 2) and an optional reason. Controlled or uncontrolled. |
| onValueChange | (vote: ComparisonVote | null) => void | — | Fires from the drag, key or press that changed the vote or its reason. |
| reasons | string[] | — | Why a winner won, offered after a vote for a side. Defaults to More accurate, Clearer, More concise, Safer advice. |
| onRerun | () => void | — | Run again was pressed: both streams replay from the start and an uncontrolled vote clears. |
| onRetry | (answerId: string) => void | — | Retry was pressed on a column whose answer has status "error". |
| onFinish | (answerId: string, stats: ComparisonStats) => void | — | A column finished playing, with its first-token time, total time, token count and rate. |
| status | "ready" | "loading" | "error" | "ready" | Whether the run has data yet: skeleton columns while loading, a message with Run again on error. |
| labels | { a: string; b: string } | { a: "A", b: "B" } | The two columns' short names, used on their badges, the stats and the vote stops. |
| label | string | "Model comparison" | The surface's accessible name. |
| sound | boolean | false | A tick for each stop the puck crosses and each reason picked; a chime when a vote lands, pitched by side. |
| disabled | boolean | false | Read only: no vote, no reason, no re-run. |
| className | string | — | Classes for the root. It is 560px tall by default; pass a height class to change it. |