{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"swatch-picker","type":"registry:ui","title":"Swatch picker","description":"A row of colour swatches with one selection ring that glides to the chosen colour, wrapping rows included. The chosen swatch steps in to make room for the ring.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/swatch-picker.tsx","type":"registry:ui","target":"components/ui/swatch-picker.tsx","content":"\"use client\";\nimport { useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./swatch-picker.css\";\n\nexport type Swatch = { value: string; label: string; color: string };\n\nexport type SwatchPickerProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** Names the choice, e.g. \"Label colour\". */\n  label: string;\n  options: Swatch[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  size?: \"sm\" | \"md\";\n};\n\n/**\n * A row of colour swatches with one selection ring that glides to the chosen colour, wrapping rows included. It is a\n * radio group: Tab lands on the chosen swatch, arrow keys move and choose, and each swatch is named by its label.\n * Under reduced motion the ring moves without travelling.\n */\nexport function SwatchPicker({ label, options, value, defaultValue, onValueChange, size = \"md\", className, style, ...props }: SwatchPickerProps) {\n  const [own, setOwn] = useState(defaultValue ?? options[0]?.value);\n  const current = value ?? own;\n  const group = useRef<HTMLDivElement>(null);\n  const [ring, setRing] = useState<{ x: number; y: number } | null>(null);\n\n  const choose = (next: string) => {\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  // The ring sits over the chosen swatch; measured, so wrapping and any gap stay right.\n  useLayoutEffect(() => {\n    const place = () => {\n      const chosen = group.current?.querySelector<HTMLElement>('[aria-checked=\"true\"]');\n      setRing(chosen ? { x: chosen.offsetLeft, y: chosen.offsetTop } : null);\n    };\n    place();\n    const observer = new ResizeObserver(place);\n    if (group.current) observer.observe(group.current);\n    return () => observer.disconnect();\n  }, [current, options]);\n\n  const key = (event: KeyboardEvent<HTMLDivElement>) => {\n    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];\n    const index = options.findIndex((option) => option.value === current);\n    let next = step === undefined ? -1 : (index + step + options.length) % options.length;\n    if (event.key === \"Home\") next = 0;\n    if (event.key === \"End\") next = options.length - 1;\n    if (next < 0) return;\n    event.preventDefault();\n    choose(options[next].value);\n    group.current?.querySelectorAll<HTMLElement>('[role=\"radio\"]')[next]?.focus();\n  };\n\n  return (\n    <div\n      ref={group}\n      role=\"radiogroup\"\n      aria-label={label}\n      data-slot=\"swatch-picker\"\n      data-size={size}\n      className={cn(\"relative flex flex-wrap\", className)}\n      style={style}\n      onKeyDown={key}\n      {...props}\n    >\n      {ring && <span data-slot=\"sp-ring\" aria-hidden=\"true\" style={{ translate: `${ring.x}px ${ring.y}px`, \"--sp-color\": options.find((option) => option.value === current)?.color } as CSSProperties} />}\n      {options.map((option) => {\n        const checked = option.value === current;\n        return (\n          <button\n            key={option.value}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={option.label}\n            title={option.label}\n            tabIndex={checked ? 0 : -1}\n            data-slot=\"sp-swatch\"\n            style={{ \"--sp-color\": option.color } as CSSProperties}\n            onClick={() => choose(option.value)}\n          />\n        );\n      })}\n    </div>\n  );\n}\n"},{"path":"ui/swatch-picker.css","type":"registry:file","target":"components/ui/swatch-picker.css","content":"/* swatch-picker.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Swatch picker: one ring travels to the chosen colour; the chosen swatch steps in to make room for it. */\n  [data-slot=\"swatch-picker\"] { --sp-size: 28px; gap: 10px; }\n  [data-slot=\"swatch-picker\"][data-size=\"sm\"] { --sp-size: 22px; gap: 8px; }\n  [data-slot=\"swatch-picker\"] [data-slot=\"sp-swatch\"] { position: relative; width: var(--sp-size); height: var(--sp-size); padding: 0; border: 0; border-radius: 50%; background: var(--sp-color); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 14%, transparent); cursor: pointer; transition: scale var(--duration-normal) var(--ease-settle); }\n  @media (hover: hover) { [data-slot=\"swatch-picker\"] [data-slot=\"sp-swatch\"]:hover { scale: 1.08; } }\n  [data-slot=\"swatch-picker\"] [data-slot=\"sp-swatch\"][aria-checked=\"true\"] { scale: 0.72; }\n  [data-slot=\"swatch-picker\"] [data-slot=\"sp-swatch\"]:active { scale: 0.66; }\n  [data-slot=\"swatch-picker\"] [data-slot=\"sp-swatch\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 4px; }\n  [data-slot=\"swatch-picker\"] [data-slot=\"sp-ring\"] { position: absolute; top: 0; left: 0; width: var(--sp-size); height: var(--sp-size); border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--sp-color) 72%, var(--foreground)); pointer-events: none; transition: translate var(--duration-travel) var(--ease-settle), box-shadow var(--duration-travel) var(--ease-out); }\n  @media (pointer: coarse) { [data-slot=\"swatch-picker\"] { --sp-size: 36px; } }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"swatch-picker\"] :is([data-slot=\"sp-ring\"], [data-slot=\"sp-swatch\"]) { transition: none; }\n  }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"surfaces","docs":"/docs/components/swatch-picker"}}