Property Comparison
Data Display

Property Comparison

A 2–3 column comparison table that highlights the best cell in each numeric row, tallies wins per column, and cross-highlights the hovered column.

Install

npx shadcn@latest add @paragon/property-comparison

property-comparison.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/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; each property header
 * counts its wins. Ties highlight all winners. Hovering a column tints the
 * whole column so cross-property scanning keeps its place, the attribute
 * column stays sticky under horizontal scroll, and rows reveal once on first
 * view with a short stagger (opacity + rise only — table rows don't blur).
 */
export function PropertyComparison({
  properties,
  rows,
  highlightBest = true,
  className,
  ...props
}: PropertyComparisonProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const reduced = useReducedMotion() ?? false;
  const shown = reduced || inView;
  const [hoverCol, setHoverCol] = React.useState<number | null>(null);

  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],
  );

  // Wins per property — shown as a quiet tally under each header.
  const wins = React.useMemo(
    () =>
      properties.map((_, col) =>
        bestIndexes.reduce((n, set) => n + (set.has(col) ? 1 : 0), 0),
      ),
    [properties, bestIndexes],
  );
  const maxWins = Math.max(0, ...wins);
  const scoredRows = bestIndexes.filter((set) => set.size > 0).length;

  return (
    <div
      ref={ref}
      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"
          onPointerLeave={() => setHoverCol(null)}
        >
          <caption className="sr-only">Property comparison</caption>
          <thead>
            <tr className="border-b border-border">
              <th
                scope="col"
                className="sticky left-0 z-10 w-40 bg-card px-4 py-3 text-left font-medium"
                onPointerEnter={() => setHoverCol(null)}
              >
                <span className="sr-only">Attribute</span>
              </th>
              {properties.map((p, col) => (
                <th
                  key={p.id}
                  scope="col"
                  onPointerEnter={() => setHoverCol(col)}
                  className={cn(
                    "px-4 py-3 text-left align-top font-medium transition-colors duration-(--duration-fast) ease-(--ease-out)",
                    hoverCol === col && "bg-muted/40",
                  )}
                >
                  <span className="block truncate" title={p.name}>
                    {p.name}
                  </span>
                  {p.subtitle && (
                    <span
                      className="block truncate text-xs font-normal text-muted-foreground"
                      title={p.subtitle}
                    >
                      {p.subtitle}
                    </span>
                  )}
                  {highlightBest && scoredRows > 0 && (
                    <span
                      className={cn(
                        "mt-1 block text-[11px] font-normal tabular-nums",
                        wins[col] === maxWins && maxWins > 0
                          ? "text-success"
                          : "text-muted-foreground/70",
                      )}
                    >
                      Best in {wins[col]} of {scoredRows}
                    </span>
                  )}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((row, rowIndex) => (
              <tr
                key={row.label}
                style={
                  shown && !reduced
                    ? { transitionDelay: `${Math.min(rowIndex, 10) * 45}ms` }
                    : undefined
                }
                className={cn(
                  "border-b border-border transition-[background-color,opacity,transform] duration-(--duration-base) ease-(--ease-out) last:border-0 hover:bg-muted/30",
                  reduced
                    ? undefined
                    : shown
                      ? "translate-y-0 opacity-100"
                      : "translate-y-1.5 opacity-0",
                )}
              >
                <th
                  scope="row"
                  onPointerEnter={() => setHoverCol(null)}
                  className="sticky left-0 z-10 bg-card px-4 py-3 text-left font-medium whitespace-nowrap 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}
                      onPointerEnter={() => setHoverCol(colIndex)}
                      aria-label={isBest ? `${display}, best in row` : undefined}
                      className={cn(
                        "px-4 py-3 tabular-nums transition-colors duration-(--duration-fast) ease-(--ease-out)",
                        isBest
                          ? "bg-success/10 font-medium text-foreground"
                          : "text-foreground",
                        hoverCol === colIndex && !isBest && "bg-muted/40",
                        hoverCol === colIndex && isBest && "bg-success/15",
                      )}
                    >
                      <span className="inline-flex items-center gap-1.5 whitespace-nowrap">
                        {display}
                        {isBest && (
                          <Check
                            aria-hidden
                            className="size-3.5 shrink-0 text-success"
                          />
                        )}
                      </span>
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}