Model Comparison

Compare AI model responses side by side in React: optional blind mode, metadata stats, and a vote bar. Evaluate LLMs in your UI. Install via the shadcn CLI.

Report a bug

When to use this in an AI app

Use Model Comparison to evaluate two model responses head-to-head. Blind mode and a vote bar make it suitable for human-preference and eval workflows.

Browse all AI agent components

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/model-comparison.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, createContext, forwardRef, type ReactNode, useCallback, useContext, useId, useMemo, useState, } from "react"; import { Eye, EyeOff } from "lucide-react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Button } from "../button/button"; const HIDDEN_LABEL_PREFIX = "Model"; /** * Localizable strings for {@link ModelComparison}. * * @public */ export type ModelComparisonLabels = { /** Caption for the cost stat. Defaults to `"Cost"`. */ cost?: string; /** Caption for the blind-mode toggle when on. Defaults to `"Hide models"`. */ hide?: string; /** Caption for the latency stat. Defaults to `"Latency"`. */ latency?: string; /** Caption for the prompt heading. Defaults to `"Prompt"`. */ prompt?: string; /** Caption for the blind-mode toggle when off. Defaults to `"Reveal models"`. */ reveal?: string; /** Caption for the token count stat. Defaults to `"Tokens"`. */ tokens?: string; /** Caption for the vote heading. Defaults to `"Which response is better?"`. */ voteHeading?: string; /** Caption for the "tie" vote option. Defaults to `"Tie"`. */ voteTie?: string; }; const DEFAULT_LABELS = { cost: "Cost", hide: "Hide models", latency: "Latency", prompt: "Prompt", reveal: "Reveal models", tokens: "Tokens", voteHeading: "Which response is better?", voteTie: "Tie", } as const satisfies Required<ModelComparisonLabels>; type ModelComparisonContextValue = { blind: boolean; labels: Required<ModelComparisonLabels>; }; const DEFAULT_CONTEXT: ModelComparisonContextValue = { blind: false, labels: DEFAULT_LABELS, }; const ModelComparisonContext = createContext(DEFAULT_CONTEXT); /** * Props for {@link ModelComparison}. * * @public */ export type ModelComparisonProps = { /** When true, replaces model labels with anonymous placeholders. */ blindDefault?: boolean; /** When true, suppresses the built-in blind-mode toggle. */ hideBlindToggle?: boolean; /** Localizable strings. */ labels?: ModelComparisonLabels; /** The prompt that drove all responses. Hidden when omitted. */ prompt?: ReactNode; } & ComponentPropsWithoutRef<"section">; type ComparisonHeaderProps = { blind: boolean; hideBlindToggle: boolean; labels: Required<ModelComparisonLabels>; onToggleBlind: () => void; prompt?: ReactNode; }; function ComparisonHeader({ blind, hideBlindToggle, labels, onToggleBlind, prompt, }: ComparisonHeaderProps): ReactNode { if (!prompt && hideBlindToggle) return null; return ( <header className="flex items-start justify-between gap-3"> {prompt ? ( <div className="flex min-w-0 flex-col gap-1"> <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> {labels.prompt} </span> <p className="text-sm text-foreground">{prompt}</p> </div> ) : ( <span aria-hidden="true" /> )} {hideBlindToggle ? null : ( <Button aria-pressed={blind} onClick={onToggleBlind} size="sm" type="button" variant="outline" > {blind ? ( <> <Eye aria-hidden="true" className="mr-2 size-4" /> {labels.reveal} </> ) : ( <> <EyeOff aria-hidden="true" className="mr-2 size-4" /> {labels.hide} </> )} </Button> )} </header> ); } /** * Side-by-side comparison of AI model responses to the same prompt. * Composes {@link Badge} and {@link Button}. * * @example * ```tsx * <ModelComparison prompt="Explain closures in JavaScript"> * <ModelComparisonColumn model="claude-sonnet-4-6" label="Sonnet"> * {sonnetResponse} * <ModelComparisonMeta tokens={320} latency="0.8s" cost="$0.003" /> * </ModelComparisonColumn> * <ModelComparisonColumn model="gpt-4o" label="GPT-4o"> * {gptResponse} * <ModelComparisonMeta tokens={410} latency="1.1s" cost="$0.005" /> * </ModelComparisonColumn> * <ModelComparisonVote onVote={handleVote} /> * </ModelComparison> * ``` * * @public */ export const ModelComparison = forwardRef<HTMLElement, ModelComparisonProps>( (props, ref) => { const { blindDefault = false, children, className, hideBlindToggle = false, labels, prompt, ...rest } = props; const resolvedLabels = useMemo( () => ({ ...DEFAULT_LABELS, ...labels }), [labels], ); const [blind, setBlind] = useState(blindDefault); const contextValue = useMemo<ModelComparisonContextValue>( () => ({ blind, labels: resolvedLabels }), [blind, resolvedLabels], ); const handleToggleBlind = useCallback(() => { setBlind((value) => !value); }, []); return ( <ModelComparisonContext.Provider value={contextValue}> <section className={cn( "flex flex-col gap-4 rounded-2xl border bg-background p-4", className, )} ref={ref} {...rest} > <ComparisonHeader blind={blind} hideBlindToggle={hideBlindToggle} labels={resolvedLabels} onToggleBlind={handleToggleBlind} prompt={prompt} /> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> {children} </div> </section> </ModelComparisonContext.Provider> ); }, ); ModelComparison.displayName = "ModelComparison"; /** * Props for {@link ModelComparisonColumn}. * * @public */ export type ModelComparisonColumnProps = { /** Optional badge text shown alongside the label (e.g. "Winner"). */ badge?: ReactNode; /** Friendly display label for the model. Hidden in blind mode. */ label?: ReactNode; /** Model identifier. Hidden in blind mode. */ model: string; } & ComponentPropsWithoutRef<"article">; /** * Column for {@link ModelComparison}. Renders the model label and badge in * the header and the response content in the body. * * @public */ export const ModelComparisonColumn = forwardRef< HTMLElement, ModelComparisonColumnProps >((props, ref) => { const { badge, children, className, label, model, ...rest } = props; const id = useId(); const { blind } = useContext(ModelComparisonContext); const displayLabel = blind ? `${HIDDEN_LABEL_PREFIX} ${id.slice(-2)}` : (label ?? model); return ( <article aria-label={typeof displayLabel === "string" ? displayLabel : undefined} className={cn( "flex flex-col gap-3 rounded-xl border border-border bg-muted/30 p-3", className, )} data-blind={blind ? "true" : "false"} data-model={blind ? undefined : model} ref={ref} {...rest} > <header className="flex items-center justify-between gap-2"> <h4 className="text-sm font-semibold tracking-tight text-foreground"> {displayLabel} </h4> {badge ? <Badge variant="secondary">{badge}</Badge> : null} </header> <div className="flex flex-1 flex-col gap-2 text-sm text-foreground"> {children} </div> </article> ); }); ModelComparisonColumn.displayName = "ModelComparisonColumn"; /** * Props for {@link ModelComparisonMeta}. * * @public */ export type ModelComparisonMetaProps = { /** Cost stat (formatted). */ cost?: ReactNode; /** Latency stat (formatted). */ latency?: ReactNode; /** Token count. */ tokens?: number | ReactNode; } & ComponentPropsWithoutRef<"dl">; /** * Inline statistics row for a {@link ModelComparisonColumn}: tokens, * latency, cost. Renders the stats whose props are present. * * @public */ export const ModelComparisonMeta = forwardRef< HTMLDListElement, ModelComparisonMetaProps >((props, ref) => { const { className, cost, latency, tokens, ...rest } = props; const { labels } = useContext(ModelComparisonContext); const items: { caption: string; value: ReactNode }[] = []; if (tokens !== undefined && tokens !== null) { items.push({ caption: labels.tokens, value: typeof tokens === "number" ? tokens.toLocaleString() : tokens, }); } if (latency !== undefined && latency !== null) { items.push({ caption: labels.latency, value: latency }); } if (cost !== undefined && cost !== null) { items.push({ caption: labels.cost, value: cost }); } if (items.length === 0) return null; return ( <dl className={cn( "mt-auto flex flex-wrap gap-x-3 gap-y-1 border-t border-border pt-2 text-xs", className, )} ref={ref} {...rest} > {items.map((item) => ( <div className="flex items-baseline gap-1" key={item.caption}> <dt className="font-medium uppercase tracking-wide text-muted-foreground"> {item.caption} </dt> <dd className="text-foreground">{item.value}</dd> </div> ))} </dl> ); }); ModelComparisonMeta.displayName = "ModelComparisonMeta"; /** * Vote payload shape passed to {@link ModelComparisonVoteProps.onVote}. * * @public */ export type ModelComparisonVoteValue = "left" | "right" | "tie"; type VoteLabels = { left?: ReactNode; right?: ReactNode; tie?: ReactNode; }; /** * Props for {@link ModelComparisonVote}. * * @public */ export type ModelComparisonVoteProps = { /** Optional captions for the left / right / tie buttons. */ buttonLabels?: VoteLabels; /** Fires with the user's choice. */ onVote?: (vote: ModelComparisonVoteValue) => void; } & ComponentPropsWithoutRef<"div">; /** * Vote bar for {@link ModelComparison}. Renders three buttons — left, tie, * right — that each emit `onVote` with the chosen value. * * @public */ export const ModelComparisonVote = forwardRef< HTMLDivElement, ModelComparisonVoteProps >((props, ref) => { const { buttonLabels, className, onVote, ...rest } = props; const { labels: contextLabels } = useContext(ModelComparisonContext); const handleLeft = useCallback(() => { onVote?.("left"); }, [onVote]); const handleTie = useCallback(() => { onVote?.("tie"); }, [onVote]); const handleRight = useCallback(() => { onVote?.("right"); }, [onVote]); return ( <div className={cn("flex flex-col items-center gap-2", className)} ref={ref} {...rest} > <p className="text-sm font-medium text-foreground"> {contextLabels.voteHeading} </p> <div className="flex flex-wrap items-center justify-center gap-2"> <Button onClick={handleLeft} size="sm" type="button" variant="outline"> {buttonLabels?.left ?? "← Left"} </Button> <Button onClick={handleTie} size="sm" type="button" variant="ghost"> {buttonLabels?.tie ?? contextLabels.voteTie} </Button> <Button onClick={handleRight} size="sm" type="button" variant="outline"> {buttonLabels?.right ?? "Right →"} </Button> </div> </div> ); }); ModelComparisonVote.displayName = "ModelComparisonVote";

Dependencies

  • @vllnt/ui@^0.2.1
  • lucide-react