A 2–3 column comparison table that highlights the best cell in each numeric row with a tint and a check.
npx shadcn@latest add @paragon/property-comparison"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>
);
}