E-commerce

Variant Picker

Size and color swatches with a sliding selection ring; out-of-stock options draw a strikethrough and can't be picked.

Install

npx shadcn@latest add @paragon/variant-picker

variant-picker.tsx

"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface VariantOption {
  value: string;
  label: string;
  /** CSS color for a color swatch; omit for a text/size chip. */
  color?: string;
  /** Out of stock: rendered with a drawn strikethrough and non-selectable. */
  soldOut?: boolean;
}

export interface VariantPickerProps
  extends Omit<
    React.ComponentProps<"div">,
    "defaultValue" | "onChange" | "children"
  > {
  options: VariantOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Accessible group name, e.g. "Size" or "Color". */
  label: string;
  /** color chips render as swatches; text chips render as labelled pills. */
  variant?: "color" | "text";
  /** Disables the sliding selection ring; selection snaps. */
  static?: boolean;
}

/**
 * A single-select swatch group for product variants. Color swatches show the
 * hue; text swatches show a size/label. A shared ring slides between the
 * selected chip (motion layoutId), out-of-stock chips draw a diagonal
 * strikethrough on mount and can't be picked. Roving-tabindex keyboard nav.
 */
export function VariantPicker({
  options,
  value: valueProp,
  defaultValue,
  onValueChange,
  label,
  variant = "text",
  static: isStatic = false,
  className,
  ...props
}: VariantPickerProps) {
  const layoutId = React.useId();
  const reduced = useReducedMotion();
  const firstAvailable = options.find((o) => !o.soldOut)?.value;
  const [uncontrolled, setUncontrolled] = React.useState(
    defaultValue ?? firstAvailable,
  );
  const value = valueProp ?? uncontrolled;
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const selectable = options.filter((o) => !o.soldOut);

  const select = (next: string) => {
    if (valueProp === undefined) setUncontrolled(next);
    onValueChange?.(next);
  };

  const move = (dir: 1 | -1, fromIndex: number) => {
    if (selectable.length === 0) return;
    const currentPos = selectable.findIndex(
      (o) => o.value === options[fromIndex]?.value,
    );
    const base = currentPos === -1 ? 0 : currentPos;
    const nextPos =
      (base + dir + selectable.length) % selectable.length;
    const nextValue = selectable[nextPos].value;
    select(nextValue);
    const domIndex = options.findIndex((o) => o.value === nextValue);
    refs.current[domIndex]?.focus();
  };

  const isColor = variant === "color";

  return (
    <>
      <style href="paragon-variant-picker" precedence="paragon">{`
        @keyframes pg-variant-strike {
          from { transform: scaleX(0); }
          to { transform: scaleX(1); }
        }
        @media (prefers-reduced-motion: reduce) {
          .pg-variant-strike { animation: none !important; transform: scaleX(1); }
        }
      `}</style>
      <div
        role="radiogroup"
        aria-label={label}
        className={cn("flex flex-wrap gap-2", className)}
        {...props}
      >
        {options.map((option, i) => {
          const selected = option.value === value;
          const disabled = option.soldOut;
          return (
            <button
              key={option.value}
              ref={(node) => {
                refs.current[i] = node;
              }}
              type="button"
              role="radio"
              aria-checked={selected}
              aria-label={
                option.soldOut ? `${option.label}, sold out` : option.label
              }
              aria-disabled={disabled || undefined}
              tabIndex={selected ? 0 : -1}
              onClick={() => !disabled && select(option.value)}
              onKeyDown={(e) => {
                if (e.key === "ArrowRight" || e.key === "ArrowDown") {
                  e.preventDefault();
                  move(1, i);
                } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
                  e.preventDefault();
                  move(-1, i);
                }
              }}
              className={cn(
                "relative inline-flex select-none items-center justify-center rounded-lg outline-none transition-[color,background-color,box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                isColor ? "size-8" : "h-9 min-w-9 px-3 text-sm font-medium",
                !isColor &&
                  !disabled &&
                  "shadow-border hover:shadow-border-hover",
                !isColor && disabled && "text-muted-foreground/50",
                !isColor && selected && "text-foreground",
                disabled && "cursor-not-allowed",
              )}
            >
              {selected && (
                <motion.span
                  layoutId={layoutId}
                  aria-hidden
                  initial={false}
                  transition={
                    isStatic || reduced
                      ? { duration: 0 }
                      : { type: "spring", duration: 0.3, bounce: 0 }
                  }
                  className={cn(
                    "pointer-events-none absolute rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background",
                    isColor ? "-inset-0" : "inset-0",
                  )}
                />
              )}

              {isColor ? (
                <span
                  aria-hidden
                  className="size-full rounded-md shadow-border"
                  style={{ backgroundColor: option.color }}
                />
              ) : (
                <span className="relative z-10">{option.label}</span>
              )}

              {disabled && (
                <span
                  aria-hidden
                  className="pg-variant-strike pointer-events-none absolute left-1/2 top-1/2 h-px w-[130%] origin-center -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-muted-foreground/70"
                  style={{
                    animation:
                      "pg-variant-strike 300ms var(--ease-out) both",
                  }}
                />
              )}
            </button>
          );
        })}
      </div>
    </>
  );
}