55 / 122 · Structured output
Comparison
Two options weighed side by side, with the pick named and argued.
Local runtimepickYou own the loop
StreamsMulti-threadSelf-hosted
External storeYour state is the source
StreamsMulti-threadSelf-hosted
You already hold messages in your own store, but you want the runtime to drive streaming and branching, so the local runtime costs you less wiring.
Installation
npx shadcn@latest add "@assistant-ui/elements-comparison-card"
Usage
import { ComparisonCard } from "@/components/elements/comparison-card";
<ComparisonCard
traitLabels={traits}
options={options}
recommendedId="local"
reason="You already hold messages in your own store."
/>Props
ComparisonCard
traitLabels*string[]The dimensions compared, in order. Used as the fallback label when an option lacks a trait.
options*ComparisonOption[]Two or three options. More than that wants a table, not a comparison.
recommendedId*stringWhich option is the pick. It is tinted and labelled rather than merely ordered first.
reason*stringWhy that one, in the user's terms. A comparison without a recommendation is an unanswered question.
classNamestringExtra classes merged onto the root.
ComparisonOption
id*stringStable identity, compared against recommendedId.
name*stringOption name.
headline*stringOne-line summary under the name.
traits*(string | false)[]Positional against traitLabels. A string is what the option does; false means it lacks the trait and shows the label struck back.
Source
comparison-card.tsx"use client";
import { CheckIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, mono, paper } from "./surfaces";
export interface ComparisonOption {
id: string;
name: string;
headline: string;
traits: readonly (string | false)[];
}
export function ComparisonCard({
traitLabels,
options,
recommendedId,
reason,
className,
}: {
traitLabels: readonly string[];
options: readonly ComparisonOption[];
recommendedId: string;
reason: string;
className?: string;
}) {
return (
<div
className={cn(
paper,
"flex w-full max-w-md flex-col gap-3 rounded-2xl p-4",
className,
)}
>
<div className="flex gap-2">
{options.map((option) => {
const recommended = option.id === recommendedId;
return (
<div
key={option.id}
className={cn(
"flex min-w-0 flex-1 flex-col gap-2 rounded-xl p-3 transition-colors",
recommended ? "bg-blue-500/[0.07] dark:bg-blue-400/10" : field,
)}
>
<div className="flex flex-col gap-0.5">
<span className="flex items-baseline gap-1.5">
<span className="truncate text-[13px] font-medium">
{option.name}
</span>
{recommended && (
<span
className={cn(
mono,
"shrink-0 text-blue-600 dark:text-blue-400",
)}
>
pick
</span>
)}
</span>
<span className="text-foreground/45 truncate text-xs">
{option.headline}
</span>
</div>
<div className="flex flex-col gap-1">
{traitLabels.map((label, i) => {
const trait = option.traits[i];
const absent = !trait;
return (
<span
key={`${i}-${label}`}
className="flex items-center gap-1.5 text-xs"
>
{absent ? (
<MinusIcon className="text-foreground/20 size-3 shrink-0" />
) : (
<CheckIcon className="size-3 shrink-0 text-emerald-500" />
)}
<span
className={cn(
"min-w-0 truncate",
absent ? "text-foreground/30" : "text-foreground/65",
)}
>
{absent ? label : trait}
</span>
</span>
);
})}
</div>
</div>
);
})}
</div>
<p className="text-foreground/55 text-xs leading-relaxed">{reason}</p>
</div>
);
}