Data Display

Property Comparison

A 2–3 column comparison table that highlights the best cell in each numeric row with a tint and a check.

Install

npx shadcn@latest add @paragon/property-comparison

property-comparison.tsx

"use client";

import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ComparisonProperty {
  id: string;
  name: string;
  /** Secondary line, e.g. address or price. */
  subtitle?: string;
}

export interface ComparisonRow {
  /** Row label. */
  label: string;
  /** One value per property, aligned by index. */
  values: Array<string | number>;
  /** Which direction is "best" for highlighting. "none" disables it. */
  best?: "high" | "low" | "none";
  /** Formats a numeric value for display. */
  format?: (value: number) => string;
}

export interface PropertyComparisonProps
  extends React.ComponentProps<"div"> {
  properties: ComparisonProperty[];
  rows: ComparisonRow[];
  /** Turns on the per-row best-cell highlight. */
  highlightBest?: boolean;
}

/**
 * A 2–3 column property comparison table. For each numeric row the best cell
 * (highest or lowest, per the row's rule) gets a tinted highlight and a
 * check, computed deterministically from the data. Ties highlight all
 * winners. The highlight uses a static background — no per-render randomness.
 */
export function PropertyComparison({
  properties,
  rows,
  highlightBest = true,
  className,
  ...props
}: PropertyComparisonProps) {
  const bestIndexes = React.useMemo(
    () =>
      rows.map((row) => {
        if (!highlightBest || row.best === "none" || !row.best)
          return new Set<number>();
        const numeric = row.values.map((v) =>
          typeof v === "number" ? v : Number.NaN,
        );
        const valid = numeric.filter((n) => !Number.isNaN(n));
        if (valid.length === 0) return new Set<number>();
        const target =
          row.best === "high" ? Math.max(...valid) : Math.min(...valid);
        const set = new Set<number>();
        numeric.forEach((n, i) => {
          if (n === target) set.add(i);
        });
        return set;
      }),
    [rows, highlightBest],
  );

  return (
    <div
      data-slot="property-comparison"
      className={cn(
        "w-full max-w-2xl overflow-hidden rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      <div className="overflow-x-auto">
        <table className="w-full border-collapse text-sm">
          <caption className="sr-only">Property comparison</caption>
          <thead>
            <tr className="border-b border-border">
              <th scope="col" className="w-40 px-4 py-3 text-left font-medium">
                <span className="sr-only">Attribute</span>
              </th>
              {properties.map((p) => (
                <th
                  key={p.id}
                  scope="col"
                  className="px-4 py-3 text-left align-top font-medium"
                >
                  <span className="block">{p.name}</span>
                  {p.subtitle && (
                    <span className="block text-xs font-normal text-muted-foreground">
                      {p.subtitle}
                    </span>
                  )}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((row, rowIndex) => (
              <tr
                key={row.label}
                className="border-b border-border last:border-0"
              >
                <th
                  scope="row"
                  className="px-4 py-3 text-left font-medium text-muted-foreground"
                >
                  {row.label}
                </th>
                {row.values.map((value, colIndex) => {
                  const isBest = bestIndexes[rowIndex].has(colIndex);
                  const display =
                    typeof value === "number" && row.format
                      ? row.format(value)
                      : String(value);
                  return (
                    <td
                      key={colIndex}
                      aria-label={isBest ? `${display}, best in row` : undefined}
                      className={cn(
                        "px-4 py-3 tabular-nums transition-colors duration-150",
                        isBest
                          ? "bg-success/10 font-medium text-foreground"
                          : "text-foreground",
                      )}
                    >
                      <span className="inline-flex items-center gap-1.5">
                        {display}
                        {isBest && (
                          <Check
                            aria-hidden
                            className="size-3.5 text-success"
                          />
                        )}
                      </span>
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}