{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-variant-cards",
  "type": "registry:component",
  "title": "Variant cards",
  "description": "Variant selector as image cards with per-serving pricing and a best value badge. Arrow key radiogroup.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "supplements",
    "wellness"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/badge.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "pdp/pdp-variant-cards.tsx",
      "content": "\"use client\"\n\nimport { useRef, useState } from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { Check } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { Badge } from \"@/components/storekit/ui/badge\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Variant selector as cards: thumb, label, price and a per-serving price so\n   the value of the larger size is obvious. A real radiogroup with arrow keys. */\n\ntype VariantCard = { id: string; label: string; image: string; price: number; servings: number; badge?: string; soldOut?: boolean }\n\nconst RING = \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n\nconst DEFAULT_OPTIONS: VariantCard[] = [\n  { id: \"30\", label: \"30 servings\", image: \"/brands/morrow/greens.webp\", price: 4900, servings: 30 },\n  { id: \"60\", label: \"60 servings\", image: \"/brands/morrow/greens.webp\", price: 8900, servings: 60, badge: \"Best value\" },\n  { id: \"bundle\", label: \"3-pouch bundle\", image: \"/brands/morrow/greens.webp\", price: 12900, servings: 90 },\n]\n\nexport function PdpVariantCards({\n  options = DEFAULT_OPTIONS,\n  defaultValue,\n  onChange,\n  label = \"Choose a size\",\n  className,\n}: {\n  options?: VariantCard[]\n  defaultValue?: string\n  onChange?: (id: string) => void\n  label?: string\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const sk = useSkBlock(\"pdp\", \"pdp-variant-cards\")\n  const [value, setValue] = useState(defaultValue ?? options.find((o) => !o.soldOut)?.id ?? options[0]?.id)\n  const refs = useRef<(HTMLButtonElement | null)[]>([])\n  const current = Math.max(0, options.findIndex((o) => o.id === value))\n\n  const select = (i: number, focus = false) => {\n    const o = options[i]\n    if (!o || o.soldOut) return\n    if (o.id !== value) sk.emit(\"interact\", \"variant\", undefined)\n    setValue(o.id)\n    onChange?.(o.id)\n    if (focus) refs.current[i]?.focus()\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const forward = e.key === \"ArrowRight\" || e.key === \"ArrowDown\"\n    const back = e.key === \"ArrowLeft\" || e.key === \"ArrowUp\"\n    if (!forward && !back) return\n    e.preventDefault()\n    const n = options.length\n    for (let s = 1; s <= n; s++) {\n      const i = (current + (forward ? s : -s) + n * 2) % n\n      if (!options[i].soldOut) return select(i, true)\n    }\n  }\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-3\", className)}>\n      <span className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">\n        {label}\n      </span>\n      <div role=\"radiogroup\" aria-label={label} className=\"grid gap-3 sm:grid-cols-3\">\n        {options.map((o, i) => {\n          const selected = o.id === value\n          return (\n            <button\n              key={o.id}\n              ref={(el) => {\n                refs.current[i] = el\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={selected}\n              aria-disabled={o.soldOut || undefined}\n              tabIndex={selected ? 0 : -1}\n              onClick={() => select(i)}\n              onKeyDown={onKeyDown}\n              className={cn(\n                \"relative flex items-center gap-3 rounded-sk-card border bg-sk-card p-3 text-left transition-[border-color,background-color] duration-200 ease-sk-out active:scale-[0.97] sm:flex-col sm:items-stretch sm:p-4\",\n                selected ? \"border-sk-accent bg-sk-accent/[0.05]\" : \"border-sk-line hover:border-sk-ink/30\",\n                o.soldOut && \"cursor-not-allowed opacity-50 active:scale-100\",\n                RING\n              )}\n            >\n              {o.badge ? (\n                <Badge tone=\"accent\" className=\"absolute -top-2.5 right-3\">\n                  {o.badge}\n                </Badge>\n              ) : null}\n              <span className=\"relative h-16 w-16 shrink-0 overflow-hidden rounded-sk-btn bg-sk-surface sm:aspect-square sm:h-auto sm:w-full\">\n                <img src={o.image} alt=\"\" loading=\"lazy\" className=\"absolute inset-0 h-full w-full object-cover\" />\n              </span>\n              <span className=\"flex min-w-0 flex-1 items-center gap-3 sm:items-start\">\n                <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                  <span className=\"text-sm font-medium text-sk-ink\">{o.label}</span>\n                  <span className=\"text-sm font-semibold tabular-nums text-sk-price\">{o.soldOut ? \"Sold out\" : money(o.price)}</span>\n                  <span className=\"font-sk-mono text-[11px] tabular-nums text-sk-muted\">{money(Math.round(o.price / o.servings))} / serving</span>\n                </span>\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"grid h-5 w-5 shrink-0 place-items-center rounded-full border transition-colors duration-200\",\n                    selected ? \"border-sk-accent bg-sk-accent text-sk-accent-ink\" : \"border-sk-line bg-sk-card\"\n                  )}\n                >\n                  <motion.span initial={false} animate={{ scale: selected ? 1 : 0, opacity: selected ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.2 }}>\n                    <Check size={12} strokeWidth={3} />\n                  </motion.span>\n                </span>\n              </span>\n            </button>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-variant-cards.tsx"
    }
  ],
  "meta": {
    "section": "pdp",
    "variant": "cards",
    "niche": [
      "supplements",
      "wellness"
    ],
    "commerce": [
      "variants"
    ],
    "previewHeight": 380
  }
}