Model Compare New
A blind side-by-side: two models stream the same prompt with live speed and cost, you vote, then their names are revealed.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-model-compare.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/ModelCompare.tsx
import { useEffect, useState } from 'react';
import { RotateCcw, Scale, ThumbsUp, Trophy, Zap } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Markdown } from './stream';
export type CompareSide = {
/** Revealed after the vote. */
model: string;
answer: string;
/** Time to first token in ms. */
ttft: number;
/** Output speed in tokens per second. */
tps: number;
/** Price per 1M output tokens in USD. */
price: number;
};
export type Vote = 'a' | 'b' | 'tie';
const CHARS_PER_TOKEN = 4.2;
function useStreamed(side: CompareSide, run: number) {
const [t, setT] = useState(0);
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setT(Infinity);
return;
}
setT(0);
const start = performance.now();
let raf = 0;
const total = side.ttft + ((side.answer.length / CHARS_PER_TOKEN) / side.tps) * 1000;
const tick = () => {
const e = performance.now() - start;
setT(e);
if (e < total) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [side, run]);
const streaming = Math.max(0, t - side.ttft);
const chars = Math.min(side.answer.length, Math.floor((streaming / 1000) * side.tps * CHARS_PER_TOKEN));
return { text: side.answer.slice(0, chars), started: t >= side.ttft, done: chars >= side.answer.length, tokens: Math.round(chars / CHARS_PER_TOKEN), elapsed: Math.min(t, side.ttft + streaming) };
}
function Column({ label, side, run, revealed, winner, faster }: { label: string; side: CompareSide; run: number; revealed: boolean; winner: boolean; faster: boolean }) {
const s = useStreamed(side, run);
const tps = s.done ? side.tps : s.started && s.elapsed > side.ttft ? Math.min(side.tps, s.tokens / ((s.elapsed - side.ttft) / 1000)) : 0;
return (
<section className={cn('flex min-w-0 flex-col rounded-2xl bg-ink-900 shadow-card ring-1 transition-all duration-500', winner ? 'ring-brand-400/50' : 'ring-transparent')}>
<header className="flex items-center gap-2 border-b border-white/5 px-4 py-2.5">
<span className="grid h-6 w-6 place-items-center rounded-md bg-white/[0.06] font-mono text-[11px] font-semibold text-ink-200">{label}</span>
<span className="relative h-5 min-w-0 flex-1 overflow-hidden">
<span className={cn('absolute inset-0 truncate text-sm text-ink-400 transition-all duration-500', revealed ? '-translate-y-full opacity-0' : 'translate-y-0')}>Model {label}</span>
<span className={cn('absolute inset-0 truncate text-sm font-medium text-ink-50 transition-all duration-500', revealed ? 'translate-y-0' : 'translate-y-full opacity-0')}>{side.model}</span>
</span>
{winner && (
<span className="flex items-center gap-1 rounded-full bg-brand-500/15 px-2 py-0.5 text-[10.5px] font-medium text-brand-300 animate-scale-in">
<Trophy className="h-3 w-3" /> Preferred
</span>
)}
{faster && s.done && !winner && (
<span className="flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-[10.5px] font-medium text-amber-300 animate-scale-in">
<Zap className="h-3 w-3" /> Faster
</span>
)}
</header>
<div className="min-h-[150px] flex-1 px-4 py-3">
{s.started ? <Markdown text={s.text} caret={!s.done} className="text-[13px]" /> : <p className="shimmer-text text-[13px] font-medium">Waiting for first token…</p>}
</div>
<dl className="grid grid-cols-4 gap-1 border-t border-white/5 px-4 py-2.5 font-mono text-[10.5px] tabular-nums">
{[
['TTFT', s.started ? `${side.ttft}ms` : '—'],
['Speed', tps ? `${Math.round(tps)} t/s` : '—'],
['Tokens', String(s.tokens)],
['Cost', `$${((s.tokens / 1e6) * side.price).toFixed(side.price < 1 ? 6 : 4)}`],
].map(([k, v]) => (
<div key={k} className="min-w-0">
<dt className="text-ink-500">{k}</dt>
<dd className="truncate text-ink-200">{v}</dd>
</div>
))}
</dl>
</section>
);
}
export type ModelCompareProps = { prompt: string; a: CompareSide; b: CompareSide; className?: string };
/** Blind side-by-side comparison: two models stream the same prompt, you vote, then their names are revealed. */
export function ModelCompare({ prompt, a, b, className }: ModelCompareProps) {
const [run, setRun] = useState(0);
const [vote, setVote] = useState<Vote | null>(null);
const rerun = () => {
setVote(null);
setRun((r) => r + 1);
};
const choices: { id: Vote; label: string }[] = [
{ id: 'a', label: 'A is better' },
{ id: 'tie', label: 'Tie' },
{ id: 'b', label: 'B is better' },
];
return (
<div className={cn('w-full space-y-3', className)}>
<div className="flex items-start gap-3 rounded-2xl bg-ink-900 px-4 py-3 shadow-card">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-white/5 text-ink-300">
<Scale className="h-4 w-4" />
</span>
<p className="min-w-0 flex-1 text-sm leading-relaxed text-ink-100">{prompt}</p>
<button onClick={rerun} aria-label="Run again" className="focus-ring grid h-7 w-7 shrink-0 place-items-center rounded-md text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100">
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 md:grid-cols-2">
<Column label="A" side={a} run={run} revealed={!!vote} winner={vote === 'a'} faster={a.tps > b.tps} />
<Column label="B" side={b} run={run} revealed={!!vote} winner={vote === 'b'} faster={b.tps > a.tps} />
</div>
<div className="flex flex-wrap items-center gap-2 rounded-2xl bg-ink-900 px-3 py-2.5 shadow-card">
<span className="px-1 text-xs text-ink-400">{vote ? (vote === 'tie' ? 'Recorded a tie.' : `You preferred ${vote === 'a' ? a.model : b.model}.`) : 'Which answer is better?'}</span>
<div className="ml-auto flex flex-wrap gap-1.5" role="group" aria-label="Vote">
{choices.map((c) => (
<button
key={c.id}
onClick={() => setVote(c.id)}
disabled={!!vote}
aria-pressed={vote === c.id}
className={cn(
'focus-ring flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs font-medium transition-all active:scale-[0.97]',
vote === c.id ? 'bg-brand-500 text-white shadow-glow' : 'text-ink-200 ring-1 ring-white/10 hover:bg-white/5 disabled:opacity-40',
)}
>
{c.id !== 'tie' && <ThumbsUp className="h-3.5 w-3.5" />}
{c.label}
</button>
))}
</div>
</div>
</div>
);
}
/* ——— demo ——— */
const A: CompareSide = {
model: 'Atlas Reasoner',
ttft: 520,
tps: 58,
price: 12,
answer:
'## In short\n- A vector database stores **embeddings**: lists of numbers that capture what text, images or audio mean.\n- It finds the closest matches by meaning, not keywords, so “cancel my plan” finds “end subscription”.\n- It powers smarter search, recommendations and AI answers grounded in your own docs.',
};
const B: CompareSide = {
model: 'Nova Mini',
ttft: 190,
tps: 124,
price: 0.6,
answer:
'- It turns content into **embeddings**, numbers that represent meaning.\n- It searches by similarity, so related ideas match even with different words.\n- Use it for semantic search, recommendations and retrieval for chatbots (`RAG`).',
};
export function ModelCompareDemo() {
return <ModelCompare prompt="Explain vector databases to a product manager in three bullets." a={A} b={B} />;
}components/niceui/ai/stream.tsx
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** Streams `text` token-by-token (words + whitespace). */
export function useStream(text: string, { speed = 32, enabled = true }: { speed?: number; enabled?: boolean } = {}) {
const [count, setCount] = useState(0);
const [run, setRun] = useState(0);
const tokens = useRef<string[]>([]);
tokens.current = text.split(/(\s+)/).filter(Boolean);
useEffect(() => {
setCount(0);
}, [text, run]);
useEffect(() => {
if (!enabled) return;
if (count >= tokens.current.length) return;
const jitter = speed * (0.5 + Math.random());
const t = window.setTimeout(() => setCount((c) => c + 2), jitter);
return () => window.clearTimeout(t);
}, [count, enabled, speed, text, run]);
const restart = useCallback(() => setRun((r) => r + 1), []);
const total = tokens.current.length;
return { output: tokens.current.slice(0, count).join(''), done: count >= total, restart, progress: total ? Math.min(1, count / total) : 1 };
}
export function Caret({ className }: { className?: string }) {
return <span aria-hidden className={cn('ml-0.5 inline-block h-[1.05em] w-[7px] translate-y-[3px] animate-blink rounded-[2px] bg-brand-400', className)} />;
}
function inline(text: string) {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((part, i) => {
if (part.startsWith('**') && part.endsWith('**') && part.length > 4)
return <strong key={i} className="font-medium text-ink-50">{part.slice(2, -2)}</strong>;
if (part.startsWith('`') && part.endsWith('`') && part.length > 2)
return <code key={i} className="rounded bg-white/[0.06] px-1 py-px font-mono text-[0.85em] text-brand-200">{part.slice(1, -1)}</code>;
return <Fragment key={i}>{part}</Fragment>;
});
}
/** Minimal markdown: `## heading`, `- bullet`, `1. step`, **bold**, `code`. */
export function Markdown({ text, caret, className }: { text: string; caret?: boolean; className?: string }) {
const blocks = text.split('\n').filter((l, i, arr) => l.trim() !== '' || i === arr.length - 1);
const last = blocks.length - 1;
const tail = (i: number): ReactNode => (caret && i === last ? <Caret /> : null);
return (
<div className={cn('space-y-2 text-sm leading-relaxed text-ink-300', className)}>
{blocks.map((line, i) => {
if (line.startsWith('## '))
return (
<h4 key={i} className="pt-1 text-[15px] font-medium text-ink-50">
{inline(line.slice(3))}
{tail(i)}
</h4>
);
const bullet = line.match(/^(- |\d+\. )(.*)$/);
if (bullet)
return (
<div key={i} className="flex gap-2.5 pl-1">
<span className={cn('shrink-0 select-none', bullet[1] === '- ' ? 'mt-[9px] h-1 w-1 rounded-full bg-brand-400' : 'font-mono text-xs leading-6 text-ink-500')}>
{bullet[1] === '- ' ? '' : bullet[1].trim()}
</span>
<p>
{inline(bullet[2])}
{tail(i)}
</p>
</div>
);
return (
<p key={i}>
{inline(line)}
{tail(i)}
</p>
);
})}
</div>
);
}