Elements

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>
  );
}