A side-by-side product spec table that highlights the best cell per row and crowns the overall winner.
npx shadcn@latest add @paragon/product-comparison"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>
);
}