{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "title": "Color Picker",
  "description": "Figma-like color picker with saturation/value canvas, hue + alpha sliders, format switching, eyedropper, and swatches.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input",
    "select"
  ],
  "files": [
    {
      "path": "components/ui/color-picker.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { Pipette } from 'lucide-react'\n\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\n\ntype PickerFormat = 'hex' | 'rgb' | 'hsl'\n\nconst FORMAT_LABELS: Record<PickerFormat, string> = { hex: 'Hex', rgb: 'RGB', hsl: 'HSL' }\n\ntype Rgb = { r: number; g: number; b: number }\ntype Hsv = { h: number; s: number; v: number }\n\ntype ColorPickerProps = {\n  value?: string\n  onChange?: (value: string) => void\n  swatches?: string[]\n}\n\ntype EyeDropperAPI = {\n  open: () => Promise<{ sRGBHex: string }>\n}\n\ndeclare global {\n  interface Window {\n    EyeDropper?: new () => EyeDropperAPI\n  }\n}\n\nconst DEFAULT_SWATCHES = [\n  '#000000',\n  '#6b7280',\n  '#d1d5db',\n  '#93c5fd',\n  '#818cf8',\n  '#ec4899',\n]\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value))\n\nconst round = (value: number, digits = 0) => {\n  const factor = 10 ** digits\n  return Math.round(value * factor) / factor\n}\n\nfunction parseHex(hex: string): { rgb: Rgb; a: number } | null {\n  const raw = hex.trim().replace('#', '')\n\n  if (!/^[0-9A-Fa-f]{3,4}$|^[0-9A-Fa-f]{6}$|^[0-9A-Fa-f]{8}$/.test(raw)) {\n    return null\n  }\n\n  const normalized =\n    raw.length <= 4\n      ? raw\n          .split('')\n          .map((char) => `${char}${char}`)\n          .join('')\n      : raw\n\n  const hasAlpha = normalized.length === 8\n  const r = Number.parseInt(normalized.slice(0, 2), 16)\n  const g = Number.parseInt(normalized.slice(2, 4), 16)\n  const b = Number.parseInt(normalized.slice(4, 6), 16)\n  const a = hasAlpha ? Number.parseInt(normalized.slice(6, 8), 16) / 255 : 1\n\n  return { rgb: { r, g, b }, a }\n}\n\nfunction rgbToHex(rgb: Rgb, alpha = 1) {\n  const base = [rgb.r, rgb.g, rgb.b]\n    .map((value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0'))\n    .join('')\n\n  if (alpha >= 1) return `#${base}`\n\n  const a = clamp(Math.round(alpha * 255), 0, 255)\n    .toString(16)\n    .padStart(2, '0')\n\n  return `#${base}${a}`\n}\n\nfunction rgbToHsv({ r, g, b }: Rgb): Hsv {\n  const rn = r / 255\n  const gn = g / 255\n  const bn = b / 255\n  const max = Math.max(rn, gn, bn)\n  const min = Math.min(rn, gn, bn)\n  const diff = max - min\n\n  let h = 0\n  if (diff !== 0) {\n    if (max === rn) h = ((gn - bn) / diff) % 6\n    else if (max === gn) h = (bn - rn) / diff + 2\n    else h = (rn - gn) / diff + 4\n  }\n\n  h = Math.round(h * 60)\n  if (h < 0) h += 360\n\n  const s = max === 0 ? 0 : diff / max\n  const v = max\n\n  return { h, s: round(s * 100, 1), v: round(v * 100, 1) }\n}\n\nfunction hsvToRgb({ h, s, v }: Hsv): Rgb {\n  const hue = ((h % 360) + 360) % 360\n  const sat = clamp(s, 0, 100) / 100\n  const val = clamp(v, 0, 100) / 100\n\n  const c = val * sat\n  const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))\n  const m = val - c\n\n  let r = 0\n  let g = 0\n  let b = 0\n\n  if (hue < 60) {\n    r = c\n    g = x\n  } else if (hue < 120) {\n    r = x\n    g = c\n  } else if (hue < 180) {\n    g = c\n    b = x\n  } else if (hue < 240) {\n    g = x\n    b = c\n  } else if (hue < 300) {\n    r = x\n    b = c\n  } else {\n    r = c\n    b = x\n  }\n\n  return {\n    r: Math.round((r + m) * 255),\n    g: Math.round((g + m) * 255),\n    b: Math.round((b + m) * 255),\n  }\n}\n\nfunction rgbToHsl({ r, g, b }: Rgb) {\n  const rn = r / 255\n  const gn = g / 255\n  const bn = b / 255\n  const max = Math.max(rn, gn, bn)\n  const min = Math.min(rn, gn, bn)\n  const l = (max + min) / 2\n  const d = max - min\n\n  let h = 0\n  let s = 0\n\n  if (d !== 0) {\n    s = d / (1 - Math.abs(2 * l - 1))\n\n    switch (max) {\n      case rn:\n        h = ((gn - bn) / d) % 6\n        break\n      case gn:\n        h = (bn - rn) / d + 2\n        break\n      default:\n        h = (rn - gn) / d + 4\n        break\n    }\n\n    h *= 60\n    if (h < 0) h += 360\n  }\n\n  return {\n    h: Math.round(h),\n    s: Math.round(s * 100),\n    l: Math.round(l * 100),\n  }\n}\n\nfunction hueToRgb(p: number, q: number, t: number) {\n  let tt = t\n  if (tt < 0) tt += 1\n  if (tt > 1) tt -= 1\n  if (tt < 1 / 6) return p + (q - p) * 6 * tt\n  if (tt < 1 / 2) return q\n  if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6\n  return p\n}\n\nfunction hslToRgb(h: number, s: number, l: number): Rgb {\n  const hn = (((h % 360) + 360) % 360) / 360\n  const sn = clamp(s, 0, 100) / 100\n  const ln = clamp(l, 0, 100) / 100\n\n  if (sn === 0) {\n    const value = Math.round(ln * 255)\n    return { r: value, g: value, b: value }\n  }\n\n  const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn\n  const p = 2 * ln - q\n\n  return {\n    r: Math.round(hueToRgb(p, q, hn + 1 / 3) * 255),\n    g: Math.round(hueToRgb(p, q, hn) * 255),\n    b: Math.round(hueToRgb(p, q, hn - 1 / 3) * 255),\n  }\n}\n\nfunction toRgbaString(rgb: Rgb, alpha: number) {\n  return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${round(alpha, 2)})`\n}\n\nexport function ColorPicker({\n  value = '#7c3aed',\n  onChange,\n  swatches = DEFAULT_SWATCHES,\n}: ColorPickerProps) {\n  const parsed = parseHex(value) ?? { rgb: { r: 124, g: 58, b: 237 }, a: 1 }\n  const [hsv, setHsv] = React.useState<Hsv>(() => rgbToHsv(parsed.rgb))\n  const [alpha, setAlpha] = React.useState(parsed.a)\n  const [format, setFormat] = React.useState<PickerFormat>('hex')\n\n  React.useEffect(() => {\n    const next = parseHex(value)\n    if (!next) return\n\n    setHsv(rgbToHsv(next.rgb))\n    setAlpha(next.a)\n  }, [value])\n\n  const rgb = hsvToRgb(hsv)\n  const hsl = rgbToHsl(rgb)\n  const hex = rgbToHex(rgb, alpha)\n\n  const emit = React.useCallback(\n    (nextHsv: Hsv, nextAlpha: number) => {\n      const nextRgb = hsvToRgb(nextHsv)\n      onChange?.(rgbToHex(nextRgb, nextAlpha))\n    },\n    [onChange],\n  )\n\n  const updatePlane = (clientX: number, clientY: number, el: HTMLElement) => {\n    const rect = el.getBoundingClientRect()\n    const s = clamp(((clientX - rect.left) / rect.width) * 100, 0, 100)\n    const v = clamp(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100)\n\n    const next = { ...hsv, s: round(s, 1), v: round(v, 1) }\n    setHsv(next)\n    emit(next, alpha)\n  }\n\n  const handlePlanePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    const target = event.currentTarget\n    target.setPointerCapture(event.pointerId)\n    updatePlane(event.clientX, event.clientY, target)\n  }\n\n  const handlePlanePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if ((event.buttons & 1) !== 1) return\n    updatePlane(event.clientX, event.clientY, event.currentTarget)\n  }\n\n  const pickFromScreen = async () => {\n    if (typeof window === 'undefined' || !window.EyeDropper) return\n\n    const eyeDropper = new window.EyeDropper()\n    try {\n      const result = await eyeDropper.open()\n      const parsedColor = parseHex(result.sRGBHex)\n      if (!parsedColor) return\n\n      const nextHsv = rgbToHsv(parsedColor.rgb)\n      setHsv(nextHsv)\n      setAlpha(1)\n      emit(nextHsv, 1)\n    } catch {\n      // User cancelled eyedropper.\n    }\n  }\n\n  const checkerboard = {\n    backgroundImage:\n      'linear-gradient(45deg, #d4d4d8 25%, transparent 25%), linear-gradient(-45deg, #d4d4d8 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d4d4d8 75%), linear-gradient(-45deg, transparent 75%, #d4d4d8 75%)',\n    backgroundSize: '10px 10px',\n    backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',\n  } as const\n\n  return (\n    <div className=\"w-full max-w-[320px] space-y-3 rounded-xl border bg-background p-3 shadow-sm\">\n      <div className=\"relative h-56 w-full overflow-hidden rounded-lg border\" style={checkerboard}>\n        <div\n          className=\"absolute inset-0\"\n          style={{ backgroundColor: `hsl(${hsv.h} 100% 50%)` }}\n        />\n        <div className=\"absolute inset-0 bg-gradient-to-r from-white to-transparent\" />\n        <div className=\"absolute inset-0 bg-gradient-to-t from-black to-transparent\" />\n\n        <div\n          className=\"absolute inset-0 cursor-crosshair touch-none\"\n          onPointerDown={handlePlanePointerDown}\n          onPointerMove={handlePlanePointerMove}\n        >\n          <div\n            className=\"absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow\"\n            style={{\n              left: `${hsv.s}%`,\n              top: `${100 - hsv.v}%`,\n              backgroundColor: toRgbaString(rgb, alpha),\n            }}\n          />\n        </div>\n      </div>\n\n      <div className=\"space-y-2\">\n        <div className=\"relative h-4 overflow-hidden rounded-full border\" style={checkerboard}>\n          <input\n            type=\"range\"\n            min={0}\n            max={360}\n            value={hsv.h}\n            onChange={(event) => {\n              const h = Number(event.target.value)\n              const next = { ...hsv, h }\n              setHsv(next)\n              emit(next, alpha)\n            }}\n            className=\"figma-range absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none rounded-full p-0\"\n            style={{\n              background:\n                'linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%)',\n            }}\n          />\n        </div>\n\n        <div className=\"relative h-4 overflow-hidden rounded-full border\" style={checkerboard}>\n          <input\n            type=\"range\"\n            min={0}\n            max={100}\n            value={Math.round(alpha * 100)}\n            onChange={(event) => {\n              const nextAlpha = Number(event.target.value) / 100\n              setAlpha(nextAlpha)\n              emit(hsv, nextAlpha)\n            }}\n            className=\"figma-range absolute inset-0 m-0 h-full w-full cursor-pointer appearance-none rounded-full p-0\"\n            style={{\n              background: `linear-gradient(to right, rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0), rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1))`,\n            }}\n          />\n        </div>\n      </div>\n\n      <div className=\"grid grid-cols-[auto_1fr_auto] items-center gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon\"\n          onClick={pickFromScreen}\n          disabled={typeof window === 'undefined' || !window.EyeDropper}\n          title=\"Eyedropper\"\n        >\n          <Pipette className=\"h-4 w-4\" />\n        </Button>\n\n        <Input\n          value={format === 'hex' ? hex.toUpperCase() : format === 'rgb' ? `${rgb.r}, ${rgb.g}, ${rgb.b}` : `${hsl.h}, ${hsl.s}%, ${hsl.l}%`}\n          onChange={(event) => {\n            const raw = event.target.value.trim()\n\n            if (format === 'hex') {\n              const parsedHex = parseHex(raw)\n              if (!parsedHex) return\n              const next = rgbToHsv(parsedHex.rgb)\n              setHsv(next)\n              setAlpha(parsedHex.a)\n              emit(next, parsedHex.a)\n              return\n            }\n\n            if (format === 'rgb') {\n              const parts = raw.split(',').map((part) => Number(part.trim()))\n              if (parts.length !== 3 || parts.some((value) => Number.isNaN(value))) return\n              const nextRgb = {\n                r: clamp(parts[0], 0, 255),\n                g: clamp(parts[1], 0, 255),\n                b: clamp(parts[2], 0, 255),\n              }\n              const next = rgbToHsv(nextRgb)\n              setHsv(next)\n              emit(next, alpha)\n              return\n            }\n\n            const parts = raw\n              .replaceAll('%', '')\n              .split(',')\n              .map((part) => Number(part.trim()))\n            if (parts.length !== 3 || parts.some((value) => Number.isNaN(value))) return\n\n            const nextRgb = hslToRgb(parts[0], parts[1], parts[2])\n            const next = rgbToHsv(nextRgb)\n            setHsv(next)\n            emit(next, alpha)\n          }}\n        />\n\n        <Select value={format} onValueChange={(value) => setFormat((value ?? 'hex') as PickerFormat)}>\n          <SelectTrigger className=\"w-[78px]\">\n            {/* Explicit label: Base UI selects show the raw value otherwise. */}\n            <SelectValue>{FORMAT_LABELS[format]}</SelectValue>\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"hex\">Hex</SelectItem>\n            <SelectItem value=\"rgb\">RGB</SelectItem>\n            <SelectItem value=\"hsl\">HSL</SelectItem>\n          </SelectContent>\n        </Select>\n      </div>\n\n      <div className=\"flex flex-wrap gap-2\">\n        {swatches.map((swatch) => (\n          <button\n            key={swatch}\n            type=\"button\"\n            className=\"h-6 w-6 rounded-md border\"\n            style={{ backgroundColor: swatch }}\n            onClick={() => {\n              const parsedSwatch = parseHex(swatch)\n              if (!parsedSwatch) return\n              const next = rgbToHsv(parsedSwatch.rgb)\n              setHsv(next)\n              setAlpha(parsedSwatch.a)\n              emit(next, parsedSwatch.a)\n            }}\n            aria-label={`Select ${swatch}`}\n          />\n        ))}\n      </div>\n\n      <style>{`\n        .figma-range::-webkit-slider-thumb {\n          -webkit-appearance: none;\n          appearance: none;\n          width: 16px;\n          height: 16px;\n          border-radius: 9999px;\n          border: 2px solid #ffffff;\n          background: #2563eb;\n          box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);\n          margin-top: -2px;\n        }\n        .figma-range::-webkit-slider-runnable-track {\n          height: 100%;\n          border-radius: 9999px;\n        }\n        .figma-range::-moz-range-thumb {\n          width: 16px;\n          height: 16px;\n          border-radius: 9999px;\n          border: 2px solid #ffffff;\n          background: #2563eb;\n          box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);\n        }\n        .figma-range::-moz-range-track {\n          height: 100%;\n          border-radius: 9999px;\n        }\n      `}</style>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "input",
    "picker"
  ],
  "type": "registry:ui"
}