{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-swatch-colors",
  "type": "registry:component",
  "title": "Color swatches",
  "description": "Color-dot radiogroup with a gliding selected ring, live color label and a diagonal strike for sold out colors.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "beauty",
    "apparel"
  ],
  "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-colors.tsx",
      "content": "\"use client\"\n\nimport { useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Color-dot swatches. A real radiogroup with arrow keys and roving focus.\n   The selected ring glides between dots, the label names the selected color\n   (or the hovered one while pointing), and sold out colors keep their dot with\n   a diagonal strike so shoppers can still pick them and ask for a restock.\n   `color` is any CSS color string; it is data, so it rides on inline style. */\n\ntype Swatch = { id: string; name: string; color: 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\"\nconst EASE = [0.16, 1, 0.3, 1] as const\n\nconst DEFAULT_SWATCHES: Swatch[] = [\n  { id: \"sand\", name: \"Sand\", color: \"rgb(222 205 180)\" },\n  { id: \"clay\", name: \"Clay\", color: \"rgb(184 120 92)\" },\n  { id: \"sage\", name: \"Sage\", color: \"rgb(150 166 136)\" },\n  { id: \"slate\", name: \"Slate\", color: \"rgb(84 96 112)\" },\n  { id: \"ink\", name: \"Ink\", color: \"rgb(28 30 36)\", soldOut: true },\n  { id: \"bone\", name: \"Bone\", color: \"rgb(246 243 236)\" },\n]\n\nexport function PdpSwatchColors({\n  swatches = DEFAULT_SWATCHES,\n  label = \"Color\",\n  defaultValue,\n  onChange,\n  className,\n}: {\n  swatches?: Swatch[]\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-colors\")\n  const [value, setValue] = useState(defaultValue ?? swatches.find((s) => !s.soldOut)?.id ?? swatches[0]?.id)\n  const [hover, setHover] = useState<string | null>(null)\n  const refs = useRef<(HTMLButtonElement | null)[]>([])\n  const current = Math.max(0, swatches.findIndex((s) => s.id === value))\n  const selected = swatches[current]\n  const shown = swatches.find((s) => s.id === hover) ?? selected\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      <div className=\"flex items-baseline gap-2 text-sm\">\n        <span id={`${uid}-label`} className=\"font-medium text-sk-ink\">\n          {label}\n        </span>\n        <span className=\"relative inline-flex h-5 min-w-0 flex-1 overflow-hidden\" aria-live=\"polite\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={shown?.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.2, ease: EASE }}\n              className=\"truncate text-sk-muted\"\n            >\n              {shown?.name}\n              {shown?.soldOut ? <span className=\"text-sk-muted/80\">, sold out</span> : null}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </div>\n      <div role=\"radiogroup\" aria-labelledby={`${uid}-label`} className=\"flex flex-wrap gap-1.5\" onMouseLeave={() => setHover(null)}>\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              title={s.name}\n              tabIndex={isSel ? 0 : -1}\n              onClick={() => select(i)}\n              onKeyDown={onKeyDown}\n              onMouseEnter={() => setHover(s.id)}\n              className={cn(\"group/sw relative grid h-11 w-11 place-items-center rounded-sk-chip transition-transform duration-200 ease-sk-out active:scale-[0.97]\", RING)}\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-chip border-2 border-sk-ink\"\n                />\n              ) : (\n                <span aria-hidden className=\"absolute inset-0 rounded-sk-chip border border-transparent transition-colors duration-200 group-hover/sw:border-sk-line\" />\n              )}\n              <span\n                aria-hidden\n                className=\"relative h-8 w-8 overflow-hidden rounded-sk-chip ring-1 ring-inset ring-sk-ink/15\"\n                style={{ background: s.color }}\n              >\n                {s.soldOut ? (\n                  <span className=\"absolute left-1/2 top-1/2 h-[1.5px] w-[140%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-sk-paper shadow-[0_0_0_0.5px_rgb(var(--sk-ink)/0.5)]\" />\n                ) : null}\n              </span>\n            </button>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-swatch-colors.tsx"
    }
  ],
  "docs": "Swatch colors are data: pass any CSS color string and it renders on inline style.",
  "meta": {
    "section": "pdp",
    "variant": "swatch",
    "niche": [
      "beauty",
      "apparel"
    ],
    "commerce": [
      "variants"
    ],
    "previewHeight": 260
  }
}