Model Comparison
Side-by-side comparison of AI model responses with optional blind mode, metadata stats, and a vote bar.
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.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook4 stories available:
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";
typescript