{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-swatch-photo",
  "type": "registry:component",
  "title": "Photo swatches",
  "description": "Photographic finish or format swatches with the selected name, detail and price under the row.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "any"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "pdp/pdp-swatch-photo.tsx",
      "content": "\"use client\"\n\nimport { useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Photographic swatches. Each option is a small photo of the actual finish\n   or format, so a pattern or texture reads before the shopper commits. The\n   selected name sits under the row with an optional detail line and price. */\n\ntype PhotoSwatch = { id: string; name: string; image: string; detail?: string; price?: number; 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\"\nconst EASE = [0.16, 1, 0.3, 1] as const\n\nconst DEFAULT_SWATCHES: PhotoSwatch[] = [\n  { id: \"pouch\", name: \"Pouch\", image: \"/brands/morrow/greens.webp\", detail: \"30 servings, scoop inside\", price: 4900 },\n  { id: \"sticks\", name: \"Travel sticks\", image: \"/brands/morrow/electrolytes.webp\", detail: \"30 single-serve sticks\", price: 5400 },\n  { id: \"bundle\", name: \"3-pouch bundle\", image: \"/brands/morrow/hero.webp\", detail: \"90 servings, ships together\", price: 12900, soldOut: true },\n]\n\nexport function PdpSwatchPhoto({\n  swatches = DEFAULT_SWATCHES,\n  label = \"Format\",\n  defaultValue,\n  onChange,\n  className,\n}: {\n  swatches?: PhotoSwatch[]\n  label?: string\n  defaultValue?: string\n  onChange?: (id: string) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"pdp\", \"pdp-swatch-photo\")\n  const [value, setValue] = useState(defaultValue ?? swatches.find((s) => !s.soldOut)?.id ?? swatches[0]?.id)\n  const refs = useRef<(HTMLButtonElement | null)[]>([])\n  const current = Math.max(0, swatches.findIndex((s) => s.id === value))\n  const selected = swatches[current]\n\n  const select = (i: number, focus = false) => {\n    const s = swatches[i]\n    if (!s) return\n    if (s.id !== value) sk.emit(\"interact\", \"swatch\", undefined)\n    setValue(s.id)\n    onChange?.(s.id)\n    if (focus) refs.current[i]?.focus()\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const n = swatches.length\n    let next = -1\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (current + 1) % n\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (current - 1 + n) % n\n    else if (e.key === \"Home\") next = 0\n    else if (e.key === \"End\") next = n - 1\n    if (next < 0) return\n    e.preventDefault()\n    select(next, true)\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 id={`${uid}-label`} className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">\n        {label}\n      </span>\n      <div role=\"radiogroup\" aria-labelledby={`${uid}-label`} className=\"flex flex-wrap gap-2.5\">\n        {swatches.map((s, i) => {\n          const isSel = s.id === value\n          return (\n            <button\n              key={s.id}\n              ref={(el) => {\n                refs.current[i] = el\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={isSel}\n              aria-label={s.soldOut ? `${s.name}, sold out` : s.name}\n              tabIndex={isSel ? 0 : -1}\n              onClick={() => select(i)}\n              onKeyDown={onKeyDown}\n              className={cn(\n                \"group/ph relative h-16 w-16 rounded-sk-btn p-1 transition-transform duration-200 ease-sk-out active:scale-[0.97] sm:h-[72px] sm:w-[72px]\",\n                RING\n              )}\n            >\n              {isSel ? (\n                <motion.span\n                  layoutId={`${uid}-ring`}\n                  aria-hidden\n                  transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}\n                  className=\"absolute inset-0 rounded-sk-btn border-2 border-sk-ink\"\n                />\n              ) : (\n                <span aria-hidden className=\"absolute inset-0 rounded-sk-btn border border-sk-line transition-colors duration-200 group-hover/ph:border-sk-ink/40\" />\n              )}\n              <span className=\"relative block h-full w-full overflow-hidden rounded-[calc(var(--sk-radius-btn)*0.7)] bg-sk-surface\">\n                <img\n                  src={s.image}\n                  alt=\"\"\n                  loading=\"lazy\"\n                  className={cn(\"absolute inset-0 h-full w-full object-cover transition-transform duration-300 ease-sk-out group-hover/ph:scale-[1.06] motion-reduce:transition-none\", s.soldOut && \"opacity-40\")}\n                />\n                {s.soldOut ? (\n                  <span aria-hidden className=\"absolute left-1/2 top-1/2 h-[1.5px] w-[150%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-sk-ink/60\" />\n                ) : null}\n              </span>\n            </button>\n          )\n        })}\n      </div>\n      <div className=\"relative min-h-[44px]\" aria-live=\"polite\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.div\n            key={selected?.id}\n            initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n            transition={{ duration: reduce ? 0 : 0.25, ease: EASE }}\n            className=\"flex items-start justify-between gap-4\"\n          >\n            <span className=\"flex min-w-0 flex-col\">\n              <span className=\"truncate text-sm font-medium text-sk-ink\">{selected?.name}</span>\n              {selected?.detail || selected?.soldOut ? (\n                <span className=\"truncate text-[13px] text-sk-muted\">{selected?.soldOut ? \"Sold out\" : selected?.detail}</span>\n              ) : null}\n            </span>\n            {typeof selected?.price === \"number\" ? (\n              <span className=\"shrink-0 text-sm font-semibold tabular-nums text-sk-price\">{money(selected.price)}</span>\n            ) : null}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-swatch-photo.tsx"
    }
  ],
  "meta": {
    "section": "pdp",
    "variant": "swatch",
    "niche": [
      "any"
    ],
    "commerce": [
      "variants"
    ],
    "previewHeight": 280
  }
}