E-commerce

Size Guide

A size-chart table with selectable rows, a sliding selection marker, and a best-fit recommendation.

Install

npx shadcn@latest add @paragon/size-guide

size-guide.tsx

"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>
  );
}