E-commerce

Product Comparison

A side-by-side product spec table that highlights the best cell per row and crowns the overall winner.

Install

npx shadcn@latest add @paragon/product-comparison

product-comparison.tsx

"use client";

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

export type SpecValue = string | number | boolean;

export interface CompareProduct {
  id: string;
  name: string;
  hue?: number;
  /** Keyed by spec row id. */
  specs: Record<string, SpecValue>;
}

export interface SpecRow {
  id: string;
  label: string;
  /** How to pick the "best" cell in this row for highlighting. */
  best?: "max" | "min" | "true";
  /** Optional unit suffix appended to numeric cells. */
  unit?: string;
}

export interface ProductComparisonProps extends React.ComponentProps<"div"> {
  products: CompareProduct[];
  rows: SpecRow[];
  /** Highlight the best cell per row. */
  highlightBest?: boolean;
}

function placeholder(hue: number) {
  return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}

/** Index of the winning product for a row, or -1 for none. */
function bestIndex(products: CompareProduct[], row: SpecRow): number {
  if (!row.best) return -1;
  let best = -1;
  let bestVal: number | null = null;
  products.forEach((p, i) => {
    const v = p.specs[row.id];
    if (row.best === "true") {
      if (v === true && best === -1) best = i;
      return;
    }
    if (typeof v !== "number") return;
    if (
      bestVal === null ||
      (row.best === "max" ? v > bestVal : v < bestVal)
    ) {
      bestVal = v;
      best = i;
    }
  });
  return best;
}

/**
 * A side-by-side product spec comparison. Sticky product header, one row per
 * spec, and (optionally) the winning cell in each row highlighted with a subtle
 * tint and a crown marker in the header of any product that wins the most rows.
 * Booleans render as check / dash. Numeric cells are tabular. The table scrolls
 * horizontally inside its own container so the page never does.
 */
export function ProductComparison({
  products,
  rows,
  highlightBest = true,
  className,
  ...props
}: ProductComparisonProps) {
  const wins = React.useMemo(() => {
    const counts = new Array(products.length).fill(0);
    rows.forEach((r) => {
      const b = bestIndex(products, r);
      if (b >= 0) counts[b] += 1;
    });
    return counts;
  }, [products, rows]);
  const topIndex = highlightBest
    ? wins.indexOf(Math.max(...wins, 0))
    : -1;

  return (
    <div
      className={cn(
        "w-full max-w-xl overflow-x-auto rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      <table className="w-full border-collapse text-sm">
        <thead>
          <tr>
            <th className="sticky left-0 z-10 bg-card p-3 text-left" />
            {products.map((p, i) => (
              <th
                key={p.id}
                scope="col"
                className={cn(
                  "min-w-32 p-3 text-center align-bottom font-medium",
                  highlightBest && i === topIndex && wins[i] > 0 && "bg-accent/40",
                )}
              >
                <span
                  aria-hidden
                  className="mx-auto mb-2 block size-12 rounded-lg"
                  style={{ background: placeholder(p.hue ?? i * 57) }}
                />
                <span className="flex items-center justify-center gap-1 text-card-foreground">
                  {highlightBest && i === topIndex && wins[i] > 0 && (
                    <Crown
                      className="size-3.5 text-warning"
                      aria-label="Best overall"
                    />
                  )}
                  {p.name}
                </span>
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row) => {
            const best = highlightBest ? bestIndex(products, row) : -1;
            return (
              <tr key={row.id} className="border-t border-border">
                <th
                  scope="row"
                  className="sticky left-0 z-10 bg-card p-3 text-left font-normal text-muted-foreground"
                >
                  {row.label}
                </th>
                {products.map((p, i) => {
                  const v = p.specs[row.id];
                  const isBest = i === best;
                  return (
                    <td
                      key={p.id}
                      className={cn(
                        "p-3 text-center tabular-nums transition-colors duration-150",
                        isBest
                          ? "font-semibold text-foreground"
                          : "text-card-foreground",
                        isBest && "bg-success/8",
                      )}
                    >
                      {typeof v === "boolean" ? (
                        v ? (
                          <Check
                            className="mx-auto size-4 text-success"
                            aria-label="Yes"
                          />
                        ) : (
                          <Minus
                            className="mx-auto size-4 text-muted-foreground"
                            aria-label="No"
                          />
                        )
                      ) : (
                        <>
                          {v}
                          {row.unit ? row.unit : ""}
                        </>
                      )}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}