{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "variant-picker",
  "title": "Variant Picker",
  "description": "Product variant picker for React and shadcn: colour swatches and sizes on Base UI radio groups, with sold-out sizes struck through.",
  "dependencies": [
    "motion",
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-number-roll.json",
    "https://eased.dev/r/eased-commerce-core.json",
    "https://eased.dev/r/eased-indicator.json",
    "https://eased.dev/r/eased-text-swap.json",
    "https://eased.dev/r/badge.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/variant-picker/variant-picker.tsx",
      "content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useId, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { COMMERCE_LOCALE, discountLabel, Price, stockState, type ProductVariant } from \"../_primitives/commerce-core/commerce-core\";\nimport { Indicator, IndicatorGroup } from \"../_primitives/indicator/indicator\";\nimport { TextSwap } from \"../_primitives/text-swap/text-swap\";\nimport { Badge } from \"../badge/badge\";\nimport { ease, exitFast, reducedFade, useSafeReducedMotion } from \"../lib/motion\";\n\n/** A variant as the picker takes it (commerce-core's `ProductVariant`). */\nexport type PickerVariant = ProductVariant;\n\nexport type VariantPickerProps = {\n  variants: PickerVariant[];\n  /** Selected variant id (controlled). */\n  value?: string;\n  /** Initially selected variant id. Default: the first in-stock variant. */\n  defaultValue?: string;\n  onValueChange?: (id: string, variant: PickerVariant) => void;\n  /** Show the price line above the options. Default true. */\n  showPrice?: boolean;\n  /** Stock at or under this reads \"Only n left\". Default 3. */\n  lowStockAt?: number;\n  /** A trailing link on the size label row, e.g. a Size guide button. */\n  sizeGuide?: ReactNode;\n  /** Text under the sizes for an in-stock variant. Default \"In stock, ships tomorrow\". */\n  inStockLabel?: string;\n  currency?: string;\n  locale?: Intl.LocalesArgument;\n  className?: string;\n};\n\nconst unique = <T,>(list: T[]) => [...new Set(list)];\n\n/**\n * Colour and size for one product, on Base UI radio groups (one tab stop each, arrows move\n * and select). The ring glides between swatches and the size highlight between sizes; sizes\n * that are sold out in the chosen colour are struck with a line that wipes across, and wipes\n * back off when another colour has them. The price rolls when the variant's price differs.\n */\nexport function VariantPicker({\n  variants,\n  value: controlled,\n  defaultValue,\n  onValueChange,\n  showPrice = true,\n  lowStockAt = 3,\n  sizeGuide,\n  inStockLabel = \"In stock, ships tomorrow\",\n  currency = \"USD\",\n  locale = COMMERCE_LOCALE,\n  className = \"\",\n}: VariantPickerProps) {\n  const id = useId();\n  const [own, setOwn] = useState(defaultValue ?? (variants.find((v) => v.stock > 0) ?? variants[0])?.id);\n  const selected = variants.find((v) => v.id === (controlled ?? own)) ?? variants[0];\n  const colors = unique(variants.map((v) => v.color));\n  const sizes = unique(variants.map((v) => v.size));\n  const oneSize = sizes.length === 1;\n  const find = (color: string, size: string) => variants.find((v) => v.color === color && v.size === size);\n\n  const pick = (next: PickerVariant | undefined) => {\n    if (!next || next.id === selected.id) return;\n    if (controlled === undefined) setOwn(next.id);\n    onValueChange?.(next.id, next);\n  };\n  // A new colour keeps the size when that colour comes in it; otherwise its first in-stock size.\n  const pickColor = (color: string) =>\n    pick(find(color, selected.size) ?? variants.find((v) => v.color === color && v.stock > 0) ?? variants.find((v) => v.color === color));\n\n  const state = stockState(selected.stock, lowStockAt);\n  const where = oneSize ? selected.color : `${selected.color}, ${selected.size}`;\n  const stockText = state === \"out\" ? `Sold out in ${where}` : state === \"low\" ? `Only ${selected.stock} left in ${where}` : inStockLabel;\n  const off = discountLabel(selected.price, selected.compareAt, locale);\n\n  return (\n    <div className={`flex min-w-0 flex-col gap-5 ${className}`}>\n      {showPrice && (\n        <div className=\"flex items-center gap-2\">\n          <Price value={selected.price} compareAt={selected.compareAt} currency={currency} locale={locale} className=\"text-ui-lg font-medium\" />\n          <AnimatePresence initial={false}>\n            {off && (\n              <motion.span key=\"off\" className=\"inline-flex\" exit={{ opacity: 0, transition: exitFast }}>\n                <Badge tone=\"success\" aria-label={`${off.replace(\"−\", \"\")} off`}>\n                  {off}\n                </Badge>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n      )}\n\n      {colors.length > 1 && (\n        <div className=\"flex flex-col gap-2\">\n          <p id={`${id}-color`} className=\"flex gap-1.5 text-ui-sm\">\n            <span className=\"text-text-muted\">Colour</span>\n            <TextSwap className=\"font-medium text-text\">{selected.color}</TextSwap>\n          </p>\n          <RadioGroup value={selected.color} onValueChange={(c) => pickColor(c as string)} aria-labelledby={`${id}-color`} className=\"flex flex-wrap gap-2\">\n            <IndicatorGroup>\n              {colors.map((color) => {\n                const all = variants.filter((v) => v.color === color);\n                const soldOut = all.every((v) => v.stock <= 0);\n                return (\n                  <Radio.Root\n                    key={color}\n                    value={color}\n                    aria-label={soldOut ? `${color}, sold out` : color}\n                    // The selected swatch sits above its siblings, so the ring never passes under a later swatch.\n                    className={`group relative flex size-9 cursor-pointer items-center justify-center rounded-pill outline-none focus-ring ${color === selected.color ? \"z-1\" : \"\"}`}\n                  >\n                    {color === selected.color && <Indicator name=\"swatch\" className=\"inset-0 rounded-pill border-[1.5px] border-text\" />}\n                    <span\n                      aria-hidden\n                      style={{ \"--swatch\": all[0].swatch } as CSSProperties}\n                      className=\"relative size-7 overflow-hidden rounded-pill bg-(--swatch) image-ring after:rounded-[inherit]\"\n                    >\n                      {soldOut && <span className=\"absolute top-1/2 left-1/2 h-px w-[140%] -translate-1/2 -rotate-45 bg-surface/80\" />}\n                    </span>\n                  </Radio.Root>\n                );\n              })}\n            </IndicatorGroup>\n          </RadioGroup>\n        </div>\n      )}\n\n      {!oneSize && (\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex h-5 items-center justify-between gap-3\">\n            <p id={`${id}-size`} className=\"flex gap-1.5 text-ui-sm\">\n              <span className=\"text-text-muted\">Size</span>\n              <TextSwap className=\"font-medium text-text\">{selected.size}</TextSwap>\n            </p>\n            {sizeGuide}\n          </div>\n          <RadioGroup\n            value={selected.size}\n            onValueChange={(s) => pick(find(selected.color, s as string))}\n            aria-labelledby={`${id}-size`}\n            style={{ gridTemplateColumns: `repeat(${Math.min(sizes.length, 6)}, minmax(0, 1fr))` }}\n            className=\"grid gap-2\"\n          >\n            <IndicatorGroup>\n              {sizes.map((size) => {\n                const v = find(selected.color, size);\n                const out = !v || v.stock <= 0;\n                return (\n                  <Radio.Root\n                    key={size}\n                    value={size}\n                    disabled={!v}\n                    aria-label={out ? `${size}, sold out` : size}\n                    className={`relative flex h-control-md cursor-pointer items-center justify-center rounded-control border border-line text-ui-sm font-medium outline-none select-none focus-ring transition-colors duration-150 ease-standard ${\n                      out ? \"text-text-muted\" : \"text-text hover-fine:bg-surface-hover\"\n                    }`}\n                  >\n                    {size === selected.size && <Indicator name=\"size\" className=\"-inset-px rounded-control border-[1.5px] border-text\" />}\n                    <span className=\"relative tabular-nums\">{size}</span>\n                    <Strike on={out} />\n                  </Radio.Root>\n                );\n              })}\n            </IndicatorGroup>\n          </RadioGroup>\n        </div>\n      )}\n\n      <p className=\"flex h-5 items-center gap-2 text-ui-sm text-text-secondary\" aria-live=\"polite\">\n        <span aria-hidden className={`size-1.5 shrink-0 rounded-pill transition-colors duration-150 ${state === \"out\" ? \"bg-text-muted\" : state === \"low\" ? \"bg-warning\" : \"bg-success\"}`} />\n        <TextSwap>{stockText}</TextSwap>\n      </p>\n    </div>\n  );\n}\n\n/** The sold-out line: wipes in from the left, and back out to the right when stock returns. */\nfunction Strike({ on }: { on: boolean }) {\n  const reduced = useSafeReducedMotion();\n  return (\n    <motion.span\n      aria-hidden\n      className=\"pointer-events-none absolute top-1/2 left-1/2 -ml-3.5 h-px w-7 bg-text-muted\"\n      initial={false}\n      animate={on ? { scaleX: 1, originX: 0, opacity: 1 } : { scaleX: 0, originX: 1, opacity: reduced ? 0 : 1 }}\n      transition={reduced ? reducedFade : on ? { duration: 0.28, ease: ease.enter } : { ...exitFast, duration: 0.18 }}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/variant-picker/variant-picker.tsx"
    }
  ],
  "meta": {
    "details": "Colour and size for one product: the ring glides between swatches, sold-out sizes strike with a wipe, and the price rolls when the variant's price changes.",
    "industries": [
      "ecommerce"
    ],
    "releaseAt": "2026-10-23"
  },
  "docs": "Import VariantPicker from components/eased/variant-picker/variant-picker. Pass variants ({ id, color, swatch, size, stock, price, compareAt }) and value/onValueChange; put a Size guide link in sizeGuide.",
  "type": "registry:ui"
}