Side-by-side model response comparison card with per-model latency, cost telemetry and one-click preference voting.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/model-arena
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { ModelArena } from "@/components/nextui/model-arena";45export function EvalCompare() {6return (7<ModelArena8prompt="How should we stop duplicate webhook charges?"9onVote={(winner) => console.log("Preferred candidate:", winner)}10/>11);12}
| Prop | Type | Default | Description |
|---|---|---|---|
| prompt | string | "How should we stop duplicate webhook charges?" | Prompt shown in the top bar |
| left | ArenaCandidate | – | Left model candidate (model, latencyMs, costUsd, response) |
| right | ArenaCandidate | – | Right model candidate (model, latencyMs, costUsd, response) |
| onVote | (winner: "a" | "b" | "tie") => void | – | Called with the preferred side, or tie, when the user votes |
| blind | boolean | "true" | Hide model names until a vote is cast |
| autoPlay | boolean | false | Loop a scripted side-by-side vote demo |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronLeft, ChevronRight, Crown, Equal, RotateCcw, Swords } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type ArenaCandidate = {
id: "a" | "b";
model: string;
/** Total generation time in ms. */
latencyMs: number;
costUsd: number;
response: string;
tokensPerSec?: number;
ttftMs?: number;
};
export type ArenaVote = "a" | "b" | "tie";
export type ModelArenaProps = {
prompt?: string;
left?: ArenaCandidate;
right?: ArenaCandidate;
/** Hide model names until the user votes. */
blind?: boolean;
onVote?: (winner: ArenaVote) => void;
/** Loop a scripted stream → vote → reveal demo. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_LEFT: ArenaCandidate = {
id: "a",
model: "kite-large",
latencyMs: 2900,
costUsd: 0.006,
tokensPerSec: 84,
ttftMs: 310,
response:
"Store each webhook's event ID under a UNIQUE constraint and apply the balance change in the same transaction. A retry then fails fast instead of crediting twice.",
};
const DEFAULT_RIGHT: ArenaCandidate = {
id: "b",
model: "ember-2-pro",
latencyMs: 3700,
costUsd: 0.004,
tokensPerSec: 61,
ttftMs: 520,
response:
"Require an Idempotency-Key on the handler, cache the first response for 24h, and replay it on retries so the charge runs exactly once.",
};
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9400;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
/** Share of the response streamed at time t, 0–1. */
function streamed(c: ArenaCandidate, t: number) {
const start = c.ttftMs ?? 300;
return Math.max(0, Math.min(1, (t - start) / Math.max(1, c.latencyMs - start)));
}
/** Two responses streamed side by side, with live tok/s, TTFT and tokens per side, then a blind Prefer left / Tie / Prefer right vote that reveals the models. */
export function ModelArena({
prompt = "How should we stop duplicate webhook charges?",
left = DEFAULT_LEFT,
right = DEFAULT_RIGHT,
blind = true,
onVote,
autoPlay = false,
className,
}: ModelArenaProps) {
const reduce = useReducedMotion() ?? false;
const [touched, setTouched] = useState(false);
const [vote, setVote] = useState<ArenaVote | null>(null);
const demo = autoPlay && !reduce && !touched;
const { ref, t } = useDemoClock(demo, LOOP);
const clock = demo ? t : 99999;
const winner: ArenaVote | null = demo ? (t >= 4800 ? "a" : null) : vote;
const pressing: ArenaVote | null = demo && t >= 4500 && t < 4800 ? "a" : null;
const bothDone = streamed(left, clock) >= 1 && streamed(right, clock) >= 1;
const revealed = !blind || winner !== null;
const cast = (v: ArenaVote) => {
setTouched(true);
setVote(v);
onVote?.(v);
};
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Prompt */}
<div className="flex items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 py-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg border bg-background shadow-xs">
<Swords className="size-4" aria-hidden />
</span>
<p className="min-w-0 flex-1 text-xs leading-snug font-medium">{prompt}</p>
{winner && (
<button
type="button"
onClick={() => {
setTouched(true);
setVote(null);
}}
aria-label="Vote again"
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3.5" aria-hidden />
</button>
)}
</div>
{/* Panels */}
<div className="grid grid-cols-2 gap-2 px-3">
{[left, right].map((c, i) => {
const p = streamed(c, clock);
const text = c.response.slice(0, Math.round(c.response.length * p));
const done = p >= 1;
const waiting = p === 0;
const won = winner === c.id;
const lost = winner !== null && winner !== "tie" && !won;
const tokens = Math.round((c.response.length / 4) * p);
const liveTps = done ? (c.tokensPerSec ?? 0) : Math.round((c.tokensPerSec ?? 60) * (0.9 + 0.2 * Math.abs(Math.sin(clock / 180 + i))));
return (
<motion.div
key={c.id}
animate={{ opacity: lost ? 0.5 : 1, scale: won ? 1.01 : 1 }}
transition={{ duration: 0.4, ease: EASE }}
className={cn(
"relative flex h-[204px] flex-col overflow-hidden rounded-xl border bg-background transition-[border-color,box-shadow] duration-500",
won && "border-foreground/50 shadow-[0_0_0_3px_color-mix(in_oklab,var(--foreground)_10%,transparent)]",
)}
>
<div className="flex items-center gap-1.5 border-b px-2.5 py-1.5">
<span className="grid size-4 place-items-center rounded bg-foreground font-mono text-[9px] font-bold text-background">{c.id.toUpperCase()}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={revealed ? "name" : "blind"}
initial={reduce ? false : { opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={reduce ? undefined : { opacity: 0, filter: "blur(4px)" }}
transition={{ duration: 0.4 }}
className="min-w-0 flex-1 truncate font-mono text-[11px] font-medium"
>
{revealed ? c.model : `Model ${c.id.toUpperCase()}`}
</motion.span>
</AnimatePresence>
<AnimatePresence>
{won && (
<motion.span
initial={reduce ? false : { scale: 0, rotate: -40 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 14 }}
>
<Crown className="size-3.5 fill-amber-400 text-amber-500" aria-label="Preferred" />
</motion.span>
)}
{!won && done && (
<motion.span initial={reduce ? false : { scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="grid size-3.5 place-items-center rounded-full bg-emerald-500 text-white">
<Check className="size-2" strokeWidth={4} aria-hidden />
</motion.span>
)}
</AnimatePresence>
</div>
<p className="flex-1 overflow-hidden px-2.5 py-2 text-[11.5px] leading-[1.55]" aria-live="off">
{waiting ? (
<span className="inline-flex gap-1 pt-1" aria-label="Waiting for first token">
{[0, 1, 2].map((d) => (
<span key={d} className={cn("size-1.5 rounded-full bg-muted-foreground/40", !reduce && "animate-pulse")} style={{ animationDelay: `${d * 150}ms` }} />
))}
</span>
) : (
<>
{text}
{!done && <span className="ma-caret ml-0.5 inline-block h-3 w-1.5 translate-y-0.5 rounded-[1px] bg-foreground" />}
</>
)}
</p>
<div className="grid grid-cols-3 border-t font-mono text-[9.5px] text-muted-foreground tabular-nums">
<Metric label="tok/s" value={waiting ? "—" : String(liveTps)} live={!done && !waiting} />
<Metric label="ttft" value={clock >= (c.ttftMs ?? 0) ? `${c.ttftMs ?? "—"}ms` : "—"} />
<Metric label={done ? `${(c.latencyMs / 1000).toFixed(1)}s` : "tokens"} value={String(tokens)} />
</div>
{!done && !waiting && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-muted" aria-hidden>
<span className="block h-full bg-foreground/60" style={{ width: `${p * 100}%` }} />
</span>
)}
</motion.div>
);
})}
</div>
{/* Vote */}
<div className="p-3">
<AnimatePresence mode="wait" initial={false}>
{winner === null ? (
<motion.div
key="vote"
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: bothDone ? 1 : 0.5, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -6 }}
transition={{ duration: 0.25 }}
className="grid grid-cols-[1fr_auto_1fr] gap-1.5"
>
<VoteButton onClick={() => cast("a")} disabled={!bothDone} pressing={pressing === "a"}>
<ChevronLeft className="size-3.5" aria-hidden />
Prefer left
</VoteButton>
<VoteButton onClick={() => cast("tie")} disabled={!bothDone} aria-label="Tie">
<Equal className="size-3.5" aria-hidden />
</VoteButton>
<VoteButton onClick={() => cast("b")} disabled={!bothDone}>
Prefer right
<ChevronRight className="size-3.5" aria-hidden />
</VoteButton>
</motion.div>
) : (
<motion.div
key="result"
role="status"
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE }}
>
<div className="flex items-center justify-between text-[11px]">
<span className="font-medium">
{winner === "tie" ? "Marked as a tie" : `You preferred ${(winner === "a" ? left : right).model}`}
</span>
<span className="font-mono text-[10px] text-muted-foreground">your votes · 30 days</span>
</div>
<div className="mt-1.5 flex h-5 gap-0.5 overflow-hidden rounded-md font-mono text-[10px] font-medium">
<motion.span
initial={reduce ? false : { width: "50%" }}
animate={{ width: "63%" }}
transition={{ duration: 0.8, ease: EASE, delay: 0.15 }}
className="flex items-center bg-foreground pl-2 text-background"
>
{left.model} 63%
</motion.span>
<span className="flex flex-1 items-center justify-end bg-muted pr-2 text-muted-foreground">37%</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<style>{`
.ma-caret { animation: ma-caret 0.9s steps(1) infinite; }
@keyframes ma-caret { 50% { opacity: 0; } }
`}</style>
</div>
);
}
function Metric({ label, value, live }: { label: string; value: string; live?: boolean }) {
return (
<span className="flex flex-col items-center border-r py-1 last:border-0">
<span className={cn("text-[11px] font-semibold text-foreground", live && "text-sky-700 dark:text-sky-300")}>{value}</span>
{label}
</span>
);
}
function VoteButton({
children,
onClick,
disabled,
pressing,
...rest
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
pressing?: boolean;
"aria-label"?: string;
}) {
return (
<motion.button
type="button"
onClick={onClick}
disabled={disabled}
animate={{ scale: pressing ? 0.94 : 1 }}
transition={{ type: "spring", stiffness: 600, damping: 22 }}
className={cn(
"inline-flex h-8 cursor-pointer items-center justify-center gap-1 rounded-lg border bg-background px-3 text-xs font-medium outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-background",
pressing && "border-foreground bg-foreground text-background hover:bg-foreground",
)}
{...rest}
>
{children}
</motion.button>
);
}
export default function ModelArenaDemo() {
return (
<div className="flex h-[380px] w-full items-start justify-center pt-2">
<ModelArena autoPlay />
</div>
);
}