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.
npx shadcn@latest add @paragon/property-comparison"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>
);
}