A size-chart table with selectable rows, a sliding selection marker, and a best-fit recommendation.
npx shadcn@latest add @paragon/size-guide"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { Ruler, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SizeRow {
size: string;
/** Measurement columns keyed by column id. */
values: Record<string, number>;
}
export interface SizeColumn {
id: string;
label: string;
unit?: string;
}
export interface SizeGuideProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
columns: SizeColumn[];
rows: SizeRow[];
/** Recommended size (its row is highlighted). */
recommended?: string;
/** Currently selected size row. */
value?: string;
defaultValue?: string;
onValueChange?: (size: string) => void;
title?: string;
}
/**
* A size-chart table with a fit recommendation. Rows are selectable; the
* selected row carries a shared highlight that slides between rows (motion
* layoutId) and the recommended size shows a "Best fit" marker. Measurements
* are tabular so columns align. Table scrolls horizontally in its own box.
* Reduced motion snaps the highlight.
*/
export function SizeGuide({
columns,
rows,
recommended,
value: valueProp,
defaultValue,
onValueChange,
title = "Size guide",
className,
...props
}: SizeGuideProps) {
const reduced = useReducedMotion();
const layoutId = React.useId();
const [uncontrolled, setUncontrolled] = React.useState(
defaultValue ?? recommended,
);
const value = valueProp ?? uncontrolled;
const select = (size: string) => {
if (valueProp === undefined) setUncontrolled(size);
onValueChange?.(size);
};
const move = (dir: 1 | -1) => {
const i = rows.findIndex((r) => r.size === value);
const base = i < 0 ? 0 : i;
select(rows[(base + dir + rows.length) % rows.length].size);
};
return (
<div
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Ruler className="size-4 text-muted-foreground" aria-hidden />
<h3 className="text-sm font-medium text-card-foreground">{title}</h3>
<span className="ml-auto text-xs text-muted-foreground">
Measurements in inches
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-muted-foreground">
<th scope="col" className="p-3 text-left font-normal">
Size
</th>
{columns.map((c) => (
<th
key={c.id}
scope="col"
className="p-3 text-right font-normal whitespace-nowrap"
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const selected = row.size === value;
const isRec = row.size === recommended;
return (
<tr
key={row.size}
onClick={() => select(row.size)}
tabIndex={0}
role="button"
aria-pressed={selected}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(row.size);
} else if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
}
}}
className="relative cursor-pointer border-t border-border outline-none transition-colors duration-150 hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<th
scope="row"
className="relative p-3 text-left font-medium text-card-foreground"
>
{selected && (
<motion.span
layoutId={reduced ? undefined : `size-sel-${layoutId}`}
transition={{
type: "spring",
duration: 0.3,
bounce: 0,
}}
aria-hidden
className="absolute left-2 top-1/2 h-6 w-1 -translate-y-1/2 rounded-full bg-foreground"
/>
)}
<span className="flex items-center gap-1.5 pl-2">
{row.size}
{isRec && (
<span className="inline-flex items-center gap-0.5 rounded-full bg-success/12 px-1.5 py-0.5 text-[10px] font-medium text-success">
<Sparkles className="size-2.5" aria-hidden />
Best fit
</span>
)}
</span>
</th>
{columns.map((c) => (
<td
key={c.id}
className="p-3 text-right tabular-nums text-card-foreground"
>
{row.values[c.id]}
{c.unit ?? '"'}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}