nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsModel arena
Copy promptCopy prompt

Customize

Prompt shown in the top bar

Loop a scripted side-by-side vote demo

JSX · copied with Copy prompt

<ModelArena autoPlay />

Model arenaNew

Side-by-side model response comparison card with per-model latency, cost telemetry and one-click preference voting.

Installation

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}

Usage

1"use client";
2
3import { ModelArena } from "@/components/nextui/model-arena";
4
5export function EvalCompare() {
6 return (
7 <ModelArena
8 prompt="How should we stop duplicate webhook charges?"
9 onVote={(winner) => console.log("Preferred candidate:", winner)}
10 />
11 );
12}

API reference

Edit props live in the preview above, or see all 7 props
PropTypeDefaultDescription
promptstring"How should we stop duplicate webhook charges?"Prompt shown in the top bar
leftArenaCandidate–Left model candidate (model, latencyMs, costUsd, response)
rightArenaCandidate–Right model candidate (model, latencyMs, costUsd, response)
onVote(winner: "a" | "b" | "tie") => void–Called with the preferred side, or tie, when the user votes
blindboolean"true"Hide model names until a vote is cast
autoPlaybooleanfalseLoop a scripted side-by-side vote demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/model-arena.tsx
"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>
  );
}
Sign in