{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-swatches",
  "type": "registry:ui",
  "title": "Color Swatches",
  "description": "Swatch radio group with a ring that grows out and a contrast-aware check.",
  "categories": [
    "selection"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/color-swatches.tsx",
      "content": "\"use client\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Plus } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Size = \"sm\" | \"md\" | \"lg\";\nconst PX: Record<Size, number> = { sm: 20, md: 24, lg: 28 };\nconst GAP: Record<Size, number> = { sm: 8, md: 10, lg: 12 };\n\nexport type Swatch = {\n  /** Any CSS color. Hex and rgb() pick the check color automatically. */\n  value: string;\n  /** The name shown in the tooltip and read out. */\n  label: string;\n  disabled?: boolean;\n  /** Force the check color for colors that can't be parsed (var(), oklch()…). */\n  ink?: \"light\" | \"dark\";\n};\n\n// Relative luminance from #rgb, #rrggbb or rgb(a)(). Null when the format isn't one of those.\nfunction luminance(color: string): number | null {\n  let rgb: number[] | null = null;\n  const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})([0-9a-f]{2})?$/i);\n  if (hex) {\n    const h = hex[1].length === 3 ? hex[1].replace(/./g, \"$&$&\") : hex[1];\n    rgb = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));\n  } else {\n    const m = color.match(/^rgba?\\(\\s*([\\d.]+)[\\s,]+([\\d.]+)[\\s,]+([\\d.]+)/i);\n    if (m) rgb = [m[1], m[2], m[3]].map(Number);\n  }\n  if (!rgb) return null;\n  const [r, g, b] = rgb.map((c) => {\n    const s = c / 255;\n    return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n  });\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n/** \"light\" or \"dark\": whichever check color has more contrast on the swatch. */\nexport function inkFor(color: string, fallback: \"light\" | \"dark\" = \"light\") {\n  const l = luminance(color);\n  if (l === null) return fallback;\n  // Contrast against white (1.05 / (l + .05)) versus against black ((l + .05) / .05).\n  return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? \"light\" : \"dark\";\n}\n\n// Absolute light and dark inks from the theme tokens, so the check reads on the swatch in either theme.\nconst INK = { light: \"text-frame dark:text-fg\", dark: \"text-fg dark:text-frame\" } as const;\n\nexport type ColorSwatchesProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  colors: Swatch[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Adds a button that opens the system color picker; the pick joins the row as a swatch. */\n  allowCustom?: boolean;\n  customLabel?: string;\n  size?: Size;\n  disabled?: boolean;\n  /** Posts the value with a form. */\n  name?: string;\n};\n\nexport function ColorSwatches({\n  colors,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  allowCustom = false,\n  customLabel = \"Custom color\",\n  size = \"md\",\n  disabled = false,\n  name,\n  className,\n  ...rest\n}: ColorSwatchesProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [picked, setPicked] = useState<string | null>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const reduce = useReducedMotion();\n  const px = PX[size];\n\n  // A value that isn't a preset is a custom color, whether it came from the picker or from props.\n  const isPreset = colors.some((c) => c.value === value);\n  const custom = value && !isPreset ? value : picked;\n\n  const openPicker = () => {\n    const input = inputRef.current;\n    if (!input) return;\n    if (custom && /^#[0-9a-f]{6}$/i.test(custom)) input.value = custom;\n    try {\n      input.showPicker();\n    } catch {\n      input.click();\n    }\n  };\n\n  return (\n    <Tooltip.Provider delay={400} closeDelay={0}>\n      <RadioGroup\n        value={value}\n        onValueChange={(v) => setValue(v as string)}\n        disabled={disabled}\n        name={name}\n        data-size={size}\n        className={cn(\"flex flex-wrap items-center\", className)}\n        style={{ gap: GAP[size] }}\n        {...rest}\n      >\n        {colors.map((c) => (\n          <SwatchRadio key={c.value} swatch={c} px={px} />\n        ))}\n\n        {allowCustom && (\n          <span className=\"flex items-center\">\n            <AnimatePresence initial={false}>\n              {custom && (\n                <motion.span\n                  key=\"custom\"\n                  className=\"flex items-center\"\n                  initial={reduce ? { opacity: 0, width: px + GAP[size] } : { opacity: 0, width: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, width: px + GAP[size], scale: 1 }}\n                  exit={{ opacity: 0, width: 0, scale: 0.6, transition: { duration: 0.15 } }}\n                  transition={reduce ? { duration: 0.15 } : spring.snappy}\n                >\n                  <SwatchRadio swatch={{ value: custom, label: `${customLabel} ${custom}` }} px={px} />\n                </motion.span>\n              )}\n            </AnimatePresence>\n            <span className=\"relative grid\">\n              <Tooltip.Root>\n                <Tooltip.Trigger\n                  render={\n                    <button\n                      type=\"button\"\n                      aria-label={custom ? `Change ${customLabel.toLowerCase()}` : customLabel}\n                      disabled={disabled}\n                      onClick={openPicker}\n                      // The button sits inside the radio group; keep its arrow keys from moving the selection.\n                      onKeyDown={(e) => {\n                        if ([\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\", \"Home\", \"End\"].includes(e.key)) e.stopPropagation();\n                      }}\n                      className={cn(\n                        \"relative grid shrink-0 select-none place-items-center rounded-full border border-dashed border-fg-4 text-fg-3\",\n                        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-3 focus-visible:outline-fg-3\",\n                        \"transition-[border-color,color,scale] duration-150 ease-out hover:border-fg-3 hover:text-fg active:scale-[0.92] active:duration-75\",\n                        \"disabled:pointer-events-none disabled:opacity-40\",\n                        \"after:absolute after:-inset-2.5 after:content-['']\",\n                      )}\n                      style={{ width: px, height: px }}\n                    />\n                  }\n                >\n                  <Plus size={px <= 20 ? 12 : 14} />\n                </Tooltip.Trigger>\n                <SwatchTooltip>{customLabel}</SwatchTooltip>\n              </Tooltip.Root>\n              {/* Sits under the button so the system picker opens next to it. */}\n              <input\n                ref={inputRef}\n                type=\"color\"\n                tabIndex={-1}\n                aria-hidden\n                className=\"pointer-events-none absolute inset-0 -z-10 size-full opacity-0\"\n                onChange={(e) => {\n                  const hex = e.target.value.toLowerCase();\n                  setPicked(hex);\n                  setValue(hex);\n                }}\n              />\n            </span>\n          </span>\n        )}\n      </RadioGroup>\n    </Tooltip.Provider>\n  );\n}\n\nfunction SwatchRadio({ swatch, px }: { swatch: Swatch; px: number }) {\n  const ink = swatch.ink ?? inkFor(swatch.value);\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger\n        render={\n          <Radio.Root\n            value={swatch.value}\n            disabled={swatch.disabled}\n            aria-label={swatch.disabled ? `${swatch.label}, unavailable` : swatch.label}\n            className={cn(\n              \"group/swatch relative grid shrink-0 cursor-default select-none place-items-center rounded-full\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[7px] focus-visible:outline-fg-3\",\n              \"transition-[scale] duration-150 ease-out active:scale-[0.9] active:duration-75\",\n              // Still hoverable when unavailable, so the tooltip can say so; it just doesn't press.\n              \"data-disabled:cursor-not-allowed data-disabled:active:scale-100\",\n              // Reach 44px on touch without changing the drawn size.\n              \"after:absolute after:-inset-2.5 after:content-['']\",\n            )}\n            style={{ width: px, height: px }}\n          />\n        }\n      >\n        {/* The selection ring grows out from the swatch's edge to its offset. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute -inset-1 rounded-full border-[1.5px] border-fg opacity-0 scale-[0.84]\",\n            \"transition-[opacity,scale] duration-150 ease-out group-data-checked/swatch:scale-100 group-data-checked/swatch:opacity-100 group-data-checked/swatch:duration-[260ms] group-data-checked/swatch:ease-out-expo\",\n            \"motion-reduce:transition-opacity\",\n          )}\n        />\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--fg)_14%,transparent)]\",\n            \"group-data-disabled/swatch:opacity-40\",\n          )}\n          style={{ background: swatch.value }}\n        />\n        {swatch.disabled && (\n          // A slash in the swatch's own contrasting ink marks it unavailable.\n          <span aria-hidden className={cn(\"absolute inset-0 grid place-items-center\", INK[ink])}>\n            <span className=\"h-px w-[120%] rotate-[-45deg] bg-current opacity-70\" />\n          </span>\n        )}\n        <svg\n          aria-hidden\n          viewBox=\"0 0 16 16\"\n          className={cn(\"relative\", INK[ink])}\n          style={{ width: px * 0.58, height: px * 0.58 }}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <path\n            d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n            pathLength={1}\n            strokeDasharray=\"1\"\n            className=\"[stroke-dashoffset:1] transition-[stroke-dashoffset] duration-100 ease-out group-data-checked/swatch:[stroke-dashoffset:0] group-data-checked/swatch:delay-75 group-data-checked/swatch:duration-300 motion-reduce:transition-none\"\n          />\n        </svg>\n      </Tooltip.Trigger>\n      <SwatchTooltip>{swatch.disabled ? `${swatch.label} · Unavailable` : swatch.label}</SwatchTooltip>\n    </Tooltip.Root>\n  );\n}\n\nfunction SwatchTooltip({ children }: { children: React.ReactNode }) {\n  return (\n    <Tooltip.Portal>\n      <Tooltip.Positioner sideOffset={10} className=\"z-(--z-tooltip)\">\n        <Tooltip.Popup\n          className={cn(\n            \"origin-(--transform-origin) whitespace-nowrap rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] text-fg shadow-pop\",\n            \"transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n            \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:translate-y-0.5\",\n            \"data-ending-style:opacity-0 data-ending-style:duration-100\",\n            \"data-instant:transition-none\",\n          )}\n        >\n          {children}\n        </Tooltip.Popup>\n      </Tooltip.Positioner>\n    </Tooltip.Portal>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/color-swatches"
}
