{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swatch-card",
  "type": "registry:component",
  "title": "Swatch Card",
  "description": "A product card whose drawn bottle recolours as you hover the swatches and grows with the size you pick, with a price that rolls and an add button that turns into a stepper.",
  "categories": [
    "component",
    "Commerce"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/spinner.json"
  ],
  "files": [
    {
      "path": "registry/devl/swatch-card.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, HeartIcon, MinusIcon, PlusIcon, StarIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type CSSProperties, type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Swatch {\n  id: string;\n  name: string;\n  color: string;\n  accent?: string;\n  soldOut?: string[];\n}\n\nexport interface SizeOption {\n  id: string;\n  label: string;\n  price: number;\n  scale?: number;\n}\n\nexport interface SwatchCardProps {\n  name: string;\n  tagline?: string;\n  rating?: number;\n  reviews?: number;\n  swatches: Swatch[];\n  sizes: SizeOption[];\n  defaultSwatch?: string;\n  defaultSize?: string;\n  currency?: string;\n  locale?: string;\n  renderProduct?: (swatch: Swatch, size: SizeOption) => ReactNode;\n  onAdd?: (selection: { swatch: Swatch; size: SizeOption; qty: number }) => void | Promise<unknown>;\n  className?: string;\n}\n\ntype BagState = { phase: \"idle\" } | { phase: \"adding\" } | { phase: \"in\"; qty: number };\n\nexport function SwatchCard({\n  name,\n  tagline,\n  rating,\n  reviews,\n  swatches,\n  sizes,\n  defaultSwatch,\n  defaultSize,\n  currency = \"USD\",\n  locale = \"en-US\",\n  renderProduct,\n  onAdd,\n  className,\n}: SwatchCardProps) {\n  const reduce = useReducedMotion();\n  const [swatchId, setSwatchId] = useState(defaultSwatch ?? swatches[0].id);\n  const [preview, setPreview] = useState<string | null>(null);\n  const [sizeId, setSizeId] = useState(defaultSize ?? sizes[0].id);\n  const [bag, setBag] = useState<BagState>({ phase: \"idle\" });\n  const [liked, setLiked] = useState(false);\n  const ringId = useId();\n  const swatchRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const mounted = useRef(true);\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  const shownSwatch = swatches.find((s) => s.id === (preview ?? swatchId)) ?? swatches[0];\n  const swatch = swatches.find((s) => s.id === swatchId) ?? swatches[0];\n  const size = sizes.find((s) => s.id === sizeId) ?? sizes[0];\n  const soldOut = (sz: SizeOption, sw = swatch) => sw.soldOut?.includes(sz.id) ?? false;\n  const fmt = (n: number) => new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: n % 1 ? 2 : 0 }).format(n);\n\n  const choose = (id: string) => {\n    setSwatchId(id);\n    const next = swatches.find((s) => s.id === id);\n    if (next?.soldOut?.includes(sizeId)) {\n      const fallback = sizes.find((sz) => !next.soldOut?.includes(sz.id));\n      if (fallback) setSizeId(fallback.id);\n    }\n    setBag({ phase: \"idle\" });\n  };\n\n  const onSwatchKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    const i = swatches.findIndex((s) => s.id === swatchId);\n    let j = -1;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") j = (i + 1) % swatches.length;\n    if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") j = (i - 1 + swatches.length) % swatches.length;\n    if (j < 0) return;\n    event.preventDefault();\n    choose(swatches[j].id);\n    swatchRefs.current[j]?.focus();\n  };\n\n  const add = async () => {\n    if (bag.phase !== \"idle\" || soldOut(size)) return;\n    setBag({ phase: \"adding\" });\n    await onAdd?.({ swatch, size, qty: 1 });\n    if (mounted.current) setBag({ phase: \"in\", qty: 1 });\n  };\n  const changeQty = (delta: number) => {\n    if (bag.phase !== \"in\") return;\n    const qty = bag.qty + delta;\n    setBag(qty <= 0 ? { phase: \"idle\" } : { phase: \"in\", qty: Math.min(qty, 9) });\n  };\n\n  const ease = [0.23, 1, 0.32, 1] as const;\n  const stageColor = { \"--c\": shownSwatch.color } as CSSProperties;\n\n  return (\n    <div className={cn(\"w-full max-w-[320px] overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}>\n      <div\n        className=\"relative h-52 overflow-hidden bg-[color-mix(in_oklch,var(--c)_16%,var(--color-card))] transition-[background-color] duration-300 ease-out\"\n        style={stageColor}\n      >\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 bg-[radial-gradient(55%_65%_at_50%_45%,--theme(--color-white/45%),transparent)] dark:bg-[radial-gradient(55%_65%_at_50%_45%,--theme(--color-white/9%),transparent)]\"\n        />\n        <button\n          aria-label={liked ? \"Remove from wishlist\" : \"Save to wishlist\"}\n          aria-pressed={liked}\n          className=\"absolute top-3 right-3 z-10 flex size-8 cursor-pointer items-center justify-center rounded-full bg-background/80 text-foreground shadow-xs/5 outline-none backdrop-blur transition-[scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-95\"\n          onClick={() => setLiked((v) => !v)}\n          type=\"button\"\n        >\n          <motion.span animate={liked && !reduce ? { scale: [1, 1.25, 1] } : { scale: 1 }} className=\"flex\" transition={{ duration: 0.3, ease: \"easeOut\" }}>\n            <HeartIcon className={cn(\"size-4 transition-colors duration-150\", liked ? \"fill-rose-500 text-rose-500\" : \"text-foreground\")} />\n          </motion.span>\n        </button>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.div\n            animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n            className=\"absolute inset-0 flex items-end justify-center pb-5\"\n            exit={reduce ? { opacity: 0 } : { opacity: 0, x: -14, filter: \"blur(3px)\", transition: { duration: 0.16, ease: \"easeIn\" } }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, x: 14, filter: \"blur(3px)\" }}\n            key={shownSwatch.id}\n            transition={{ duration: 0.26, ease }}\n          >\n            {renderProduct ? renderProduct(shownSwatch, size) : <Bottle scale={size.scale ?? 1} swatch={shownSwatch} />}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n\n      <div className=\"p-4\">\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"min-w-0\">\n            <h3 className=\"font-semibold text-base leading-tight\">{name}</h3>\n            {tagline && <p className=\"mt-0.5 text-muted-foreground text-xs\">{tagline}</p>}\n          </div>\n          <RollingNumber className=\"font-semibold text-base\" text={fmt(size.price)} />\n        </div>\n        {rating !== undefined && (\n          <p className=\"mt-1.5 flex items-center gap-1 text-muted-foreground text-xs\">\n            <StarIcon className=\"size-3.5 fill-amber-400 text-amber-400\" />\n            <span className=\"font-medium text-foreground tabular-nums\">{rating.toFixed(1)}</span>\n            {reviews !== undefined && <span className=\"tabular-nums\">({reviews} reviews)</span>}\n          </p>\n        )}\n\n        <div className=\"mt-4\">\n          <p className=\"mb-2 text-xs\">\n            <span className=\"text-muted-foreground\">Colour</span> <span className=\"font-medium\">{shownSwatch.name}</span>\n          </p>\n          <div aria-label=\"Colour\" className=\"flex gap-1\" onKeyDown={onSwatchKey} onPointerLeave={() => setPreview(null)} role=\"radiogroup\">\n            {swatches.map((s, i) => {\n              const on = s.id === swatchId;\n              return (\n                <button\n                  aria-checked={on}\n                  aria-label={s.name}\n                  className=\"relative flex size-8 cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                  key={s.id}\n                  onBlur={() => setPreview(null)}\n                  onClick={() => choose(s.id)}\n                  onPointerEnter={(e) => e.pointerType === \"mouse\" && setPreview(s.id)}\n                  ref={(el) => {\n                    swatchRefs.current[i] = el;\n                  }}\n                  role=\"radio\"\n                  tabIndex={on ? 0 : -1}\n                  type=\"button\"\n                >\n                  {on && (\n                    <motion.span\n                      className=\"absolute inset-0 rounded-full ring-[1.5px] ring-foreground\"\n                      layoutId={`${ringId}-ring`}\n                      transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 38 }}\n                    />\n                  )}\n                  <span\n                    className=\"size-6 rounded-full shadow-[inset_0_0_0_1px_--theme(--color-black/12%),inset_0_-3px_4px_--theme(--color-black/14%)] dark:shadow-[inset_0_0_0_1px_--theme(--color-white/28%),inset_0_-3px_4px_--theme(--color-black/20%)]\"\n                    style={{ background: s.color }}\n                  />\n                </button>\n              );\n            })}\n          </div>\n        </div>\n\n        <div className=\"mt-4\">\n          <div className=\"mb-2 flex items-baseline justify-between gap-2 text-xs\">\n            <span className=\"text-muted-foreground\">Size</span>\n            {sizes.some((sz) => soldOut(sz)) && (\n              <span className=\"truncate text-muted-foreground\">\n                {sizes\n                  .filter((sz) => soldOut(sz))\n                  .map((sz) => sz.label)\n                  .join(\", \")}{\" \"}\n                sold out in {swatch.name}\n              </span>\n            )}\n          </div>\n          <div aria-label=\"Size\" className=\"grid grid-cols-3 gap-1.5\" role=\"radiogroup\">\n            {sizes.map((sz) => {\n              const on = sz.id === sizeId;\n              const out = soldOut(sz);\n              return (\n                <button\n                  aria-checked={on}\n                  aria-disabled={out || undefined}\n                  aria-label={out ? `${sz.label}, sold out in ${swatch.name}` : sz.label}\n                  className={cn(\n                    \"relative h-8 cursor-pointer overflow-hidden rounded-lg border font-medium text-xs outline-none transition-[background-color,border-color,color,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\",\n                    on ? \"border-primary bg-primary text-primary-foreground\" : \"bg-background text-foreground hover:bg-accent dark:bg-input/32\",\n                    out && \"cursor-not-allowed text-muted-foreground line-through decoration-muted-foreground/60 hover:bg-background active:scale-100\",\n                  )}\n                  key={sz.id}\n                  onClick={() => {\n                    if (out) return;\n                    setSizeId(sz.id);\n                    setBag({ phase: \"idle\" });\n                  }}\n                  role=\"radio\"\n                  type=\"button\"\n                >\n                  {sz.label}\n                </button>\n              );\n            })}\n          </div>\n        </div>\n\n        <BagButton\n          bag={bag}\n          label={`Add to bag · ${fmt(size.price)}`}\n          onAdd={add}\n          onQty={changeQty}\n          reduce={!!reduce}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction BagButton({\n  bag,\n  label,\n  onAdd,\n  onQty,\n  reduce,\n}: {\n  bag: BagState;\n  label: string;\n  onAdd: () => void;\n  onQty: (delta: number) => void;\n  reduce: boolean;\n}) {\n  const ease = [0.23, 1, 0.32, 1] as const;\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(2px)\" },\n    transition: { duration: 0.2, ease },\n  };\n  const inBag = bag.phase === \"in\";\n  return (\n    <div\n      className={cn(\n        \"relative mt-3 h-10 overflow-hidden rounded-xl transition-[background-color,box-shadow] duration-300 ease-out\",\n        inBag ? \"bg-muted shadow-[inset_0_0_0_1px_var(--color-border)]\" : \"bg-primary text-primary-foreground shadow-xs\",\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {inBag ? (\n          <motion.div className=\"absolute inset-0 flex items-center justify-between px-1\" key=\"stepper\" {...swap}>\n            <button\n              aria-label={bag.qty === 1 ? \"Remove from bag\" : \"One less\"}\n              className=\"flex size-8 cursor-pointer items-center justify-center rounded-lg text-foreground outline-none transition-[background-color,scale] hover:bg-background focus-visible:ring-2 focus-visible:ring-ring active:scale-95\"\n              onClick={() => onQty(-1)}\n              type=\"button\"\n            >\n              <MinusIcon className=\"size-4\" />\n            </button>\n            <span aria-live=\"polite\" className=\"flex items-center gap-1.5 font-medium text-sm\">\n              <CheckIcon className=\"size-4 text-success\" />\n              <RollingNumber text={String(bag.qty)} /> in your bag\n            </span>\n            <button\n              aria-label=\"One more\"\n              className=\"flex size-8 cursor-pointer items-center justify-center rounded-lg text-foreground outline-none transition-[background-color,scale] hover:bg-background focus-visible:ring-2 focus-visible:ring-ring active:scale-95\"\n              onClick={() => onQty(1)}\n              type=\"button\"\n            >\n              <PlusIcon className=\"size-4\" />\n            </button>\n          </motion.div>\n        ) : (\n          <motion.button\n            className=\"absolute inset-0 flex cursor-pointer items-center justify-center gap-2 font-medium text-sm outline-none transition-[scale] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset active:scale-[0.98]\"\n            disabled={bag.phase === \"adding\"}\n            key=\"add\"\n            onClick={onAdd}\n            type=\"button\"\n            {...swap}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {bag.phase === \"adding\" ? (\n                <motion.span className=\"flex items-center gap-2\" key=\"adding\" {...swap}>\n                  <Spinner className=\"size-4\" />\n                  Adding…\n                </motion.span>\n              ) : (\n                <motion.span key=\"label\" {...swap}>\n                  {label}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </motion.button>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Bottle({ swatch, scale }: { swatch: Swatch; scale: number }) {\n  const reduce = useReducedMotion();\n  const vars = {\n    \"--c\": swatch.color,\n    \"--cap\": swatch.accent ?? `color-mix(in oklch, ${swatch.color} 62%, black)`,\n  } as CSSProperties;\n  return (\n    <div className=\"relative flex flex-col items-center\" style={vars}>\n      <div className=\"h-4 w-6 rounded-t-full border-[4px] border-(--cap) border-b-0\" />\n      <div className=\"h-8 w-[54px] rounded-t-[10px] rounded-b-[5px] bg-[repeating-linear-gradient(90deg,var(--cap)_0_5px,color-mix(in_oklch,var(--cap)_82%,black)_5px_6px)] shadow-[inset_0_-3px_0_--theme(--color-black/18%)]\" />\n      <div className=\"h-2 w-[46px] bg-[linear-gradient(90deg,var(--color-neutral-400),var(--color-neutral-100)_45%,var(--color-neutral-300)_70%,var(--color-neutral-400))]\" />\n      <motion.div\n        animate={{ height: 128 * scale }}\n        className=\"relative w-[72px] overflow-hidden rounded-t-[18px] rounded-b-[22px] bg-[linear-gradient(90deg,color-mix(in_oklch,var(--c)_72%,black)_0%,var(--c)_26%,color-mix(in_oklch,var(--c)_78%,white)_46%,var(--c)_68%,color-mix(in_oklch,var(--c)_70%,black)_100%)]\"\n        initial={false}\n        transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 300, damping: 30 }}\n      >\n        <span className=\"absolute top-[38%] left-1/2 -translate-x-1/2 font-semibold text-[7px] text-white/75 uppercase tracking-[0.3em] mix-blend-luminosity\">\n          Fieldwell\n        </span>\n        <span className=\"absolute inset-x-0 bottom-3 h-px bg-black/10\" />\n      </motion.div>\n      <div className=\"absolute -bottom-2 h-3 w-24 rounded-[50%] bg-black/20 blur-[6px] dark:bg-black/50\" />\n    </div>\n  );\n}\n\nfunction RollingNumber({ text, className }: { text: string; className?: string }) {\n  const reduce = useReducedMotion();\n  const chars = text.split(\"\");\n  return (\n    <span aria-label={text} className={cn(\"inline-flex tabular-nums leading-6\", className)} role=\"img\">\n      {chars.map((c, i) => {\n        const key = chars.length - i;\n        if (!/\\d/.test(c)) {\n          return (\n            <span aria-hidden key={`s${key}`}>\n              {c}\n            </span>\n          );\n        }\n        return (\n          <span aria-hidden className=\"relative inline-block h-6 overflow-hidden [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)]\" key={`d${key}`}>\n            <motion.span\n              animate={{ y: `${-Number(c) * 10}%` }}\n              className=\"flex flex-col\"\n              initial={false}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 280, damping: 30 }}\n            >\n              {Array.from({ length: 10 }, (_, d) => (\n                <span className=\"h-6\" key={d}>\n                  {d}\n                </span>\n              ))}\n            </motion.span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n"
    }
  ]
}