{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "title": "Color Picker",
  "description": "A full colour picker with a saturation/hue/alpha area, hex and RGB fields, presets and an eyedropper.",
  "dependencies": [
    "@base-ui/react@^1.6.0",
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/utils",
    "@nx-ui/button",
    "@nx-ui/color-swatch",
    "@nx-ui/input",
    "@nx-ui/popover"
  ],
  "files": [
    {
      "path": "components/ui/color-picker.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Slider as SliderPrimitive } from \"@base-ui/react/slider\"\nimport { Dropper } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { ColorSwatch } from \"@/components/ui/color-swatch\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\n\n/**\n * ColorPicker — a full Untitled UI colour picker popover: saturation/value\n * area + hue/alpha sliders (custom pointer-drag, no deps), hex + rgba text\n * fields, an optional preset swatch row, and an eyedropper button backed by\n * the browser `EyeDropper` API where available.\n *\n * Colour state is kept internally as HSVA (the natural representation for\n * the SV area + hue slider) and surfaced to consumers as an rgba hex string\n * (`#rrggbbaa` when alpha < 1, `#rrggbb` otherwise) via `value`/\n * `onValueChange`. Controlled/uncontrolled, same shape as the other nx-ui\n * form primitives — pass `value` to control, or `defaultValue` and read\n * `onValueChange`.\n */\n\n// ---------------------------------------------------------------------------\n// Colour conversion helpers (HSV <-> RGB <-> hex)\n// ---------------------------------------------------------------------------\n\ntype HSVA = { h: number; s: number; v: number; a: number }\ntype RGBA = { r: number; g: number; b: number; a: number }\n\nfunction hsvToRgb({ h, s, v }: HSVA): Omit<RGBA, \"a\"> {\n  const c = v * s\n  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n  const m = v - c\n  let [r, g, b] = [0, 0, 0]\n  if (h < 60) [r, g, b] = [c, x, 0]\n  else if (h < 120) [r, g, b] = [x, c, 0]\n  else if (h < 180) [r, g, b] = [0, c, x]\n  else if (h < 240) [r, g, b] = [0, x, c]\n  else if (h < 300) [r, g, b] = [x, 0, c]\n  else [r, g, b] = [c, 0, x]\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 rgbToHsv(r: number, g: number, b: number): Omit<HSVA, \"a\"> {\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 d = max - min\n  let h = 0\n  if (d !== 0) {\n    if (max === rn) h = 60 * (((gn - bn) / d) % 6)\n    else if (max === gn) h = 60 * ((bn - rn) / d + 2)\n    else h = 60 * ((rn - gn) / d + 4)\n  }\n  if (h < 0) h += 360\n  const s = max === 0 ? 0 : d / max\n  return { h, s, v: max }\n}\n\nfunction toHex2(n: number) {\n  return Math.round(n).toString(16).padStart(2, \"0\")\n}\n\nfunction hsvaToHex({ h, s, v, a }: HSVA): string {\n  const { r, g, b } = hsvToRgb({ h, s, v, a })\n  const hex = `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`\n  return a < 1 ? `${hex}${toHex2(a * 255)}` : hex\n}\n\n/** Parses `#rgb`, `#rrggbb` or `#rrggbbaa` (also bare, without `#`). Returns null if invalid. */\nfunction hexToHsva(hex: string): HSVA | null {\n  const clean = hex.trim().replace(/^#/, \"\")\n  let r: number, g: number, b: number, a = 1\n  if (/^[0-9a-f]{3}$/i.test(clean)) {\n    r = parseInt(clean[0] + clean[0], 16)\n    g = parseInt(clean[1] + clean[1], 16)\n    b = parseInt(clean[2] + clean[2], 16)\n  } else if (/^[0-9a-f]{6}$/i.test(clean)) {\n    r = parseInt(clean.slice(0, 2), 16)\n    g = parseInt(clean.slice(2, 4), 16)\n    b = parseInt(clean.slice(4, 6), 16)\n  } else if (/^[0-9a-f]{8}$/i.test(clean)) {\n    r = parseInt(clean.slice(0, 2), 16)\n    g = parseInt(clean.slice(2, 4), 16)\n    b = parseInt(clean.slice(4, 6), 16)\n    a = parseInt(clean.slice(6, 8), 16) / 255\n  } else {\n    return null\n  }\n  return { ...rgbToHsv(r, g, b), a }\n}\n\nfunction rgbaToHsva(r: number, g: number, b: number, a: number): HSVA {\n  return { ...rgbToHsv(r, g, b), a }\n}\n\n// ---------------------------------------------------------------------------\n// Saturation/value area\n// ---------------------------------------------------------------------------\n\nfunction SaturationValueArea({\n  hsva,\n  onChange,\n  className,\n}: {\n  hsva: HSVA\n  onChange: (next: Pick<HSVA, \"s\" | \"v\">) => void\n  className?: string\n}) {\n  const ref = React.useRef<HTMLDivElement>(null)\n\n  const updateFromPointer = React.useCallback(\n    (clientX: number, clientY: number) => {\n      const el = ref.current\n      if (!el) return\n      const rect = el.getBoundingClientRect()\n      const x = Math.min(Math.max(clientX - rect.left, 0), rect.width)\n      const y = Math.min(Math.max(clientY - rect.top, 0), rect.height)\n      onChange({\n        s: rect.width === 0 ? 0 : x / rect.width,\n        v: rect.height === 0 ? 0 : 1 - y / rect.height,\n      })\n    },\n    [onChange]\n  )\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.setPointerCapture(event.pointerId)\n    updateFromPointer(event.clientX, event.clientY)\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.buttons !== 1) return\n    updateFromPointer(event.clientX, event.clientY)\n  }\n\n  const hueColor = hsvaToHex({ h: hsva.h, s: 1, v: 1, a: 1 })\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"color-picker-saturation\"\n      role=\"slider\"\n      aria-label=\"Colour saturation and brightness\"\n      aria-valuetext={`saturation ${Math.round(hsva.s * 100)}%, brightness ${Math.round(hsva.v * 100)}%`}\n      tabIndex={0}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onKeyDown={(event) => {\n        const step = event.shiftKey ? 0.1 : 0.02\n        if (event.key === \"ArrowRight\") onChange({ s: Math.min(hsva.s + step, 1), v: hsva.v })\n        else if (event.key === \"ArrowLeft\") onChange({ s: Math.max(hsva.s - step, 0), v: hsva.v })\n        else if (event.key === \"ArrowUp\") onChange({ s: hsva.s, v: Math.min(hsva.v + step, 1) })\n        else if (event.key === \"ArrowDown\") onChange({ s: hsva.s, v: Math.max(hsva.v - step, 0) })\n        else return\n        event.preventDefault()\n      }}\n      className={cn(\n        \"relative h-40 w-full touch-none rounded-lg outline-none select-none focus-visible:ring-4 focus-visible:ring-ring/25\",\n        className\n      )}\n      style={{\n        backgroundColor: hueColor,\n        backgroundImage:\n          \"linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)\",\n      }}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md ring-1 ring-black/20\"\n        style={{\n          left: `${hsva.s * 100}%`,\n          top: `${(1 - hsva.v) * 100}%`,\n          backgroundColor: hsvaToHex({ ...hsva, a: 1 }),\n        }}\n      />\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Hue / alpha sliders (thin wrappers over the shared Slider primitive)\n// ---------------------------------------------------------------------------\n\nfunction HueSlider({\n  hue,\n  onChange,\n  className,\n}: {\n  hue: number\n  onChange: (hue: number) => void\n  className?: string\n}) {\n  return (\n    <SliderPrimitive.Root\n      data-slot=\"color-picker-hue\"\n      className={cn(\"relative flex w-full touch-none items-center select-none\", className)}\n      value={[hue]}\n      min={0}\n      max={360}\n      step={1}\n      onValueChange={(next) => onChange(Array.isArray(next) ? next[0] : next)}\n    >\n      <SliderPrimitive.Control className=\"relative flex h-4 w-full items-center\">\n        <SliderPrimitive.Track\n          className=\"h-3 w-full rounded-full\"\n          style={{\n            backgroundImage:\n              \"linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)\",\n          }}\n        >\n          <SliderPrimitive.Indicator className=\"hidden\" />\n        </SliderPrimitive.Track>\n        <SliderPrimitive.Thumb\n          getAriaLabel={() => \"Colour hue\"}\n          className=\"block size-4 shrink-0 rounded-full border-2 border-white bg-transparent shadow-md ring-1 ring-black/20 outline-none select-none focus-visible:ring-4 focus-visible:ring-ring/25\"\n        />\n      </SliderPrimitive.Control>\n    </SliderPrimitive.Root>\n  )\n}\n\nfunction AlphaSlider({\n  alpha,\n  color,\n  onChange,\n  className,\n}: {\n  alpha: number\n  color: string\n  onChange: (alpha: number) => void\n  className?: string\n}) {\n  return (\n    <SliderPrimitive.Root\n      data-slot=\"color-picker-alpha\"\n      className={cn(\"relative flex w-full touch-none items-center select-none\", className)}\n      value={[Math.round(alpha * 100)]}\n      min={0}\n      max={100}\n      step={1}\n      onValueChange={(next) =>\n        onChange((Array.isArray(next) ? next[0] : next) / 100)\n      }\n    >\n      <SliderPrimitive.Control className=\"relative flex h-4 w-full items-center\">\n        <SliderPrimitive.Track\n          className=\"relative h-3 w-full overflow-hidden rounded-full\"\n          style={{\n            backgroundImage:\n              \"linear-gradient(45deg, var(--color-border) 25%, transparent 25%), linear-gradient(-45deg, var(--color-border) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--color-border) 75%), linear-gradient(-45deg, transparent 75%, var(--color-border) 75%)\",\n            backgroundSize: \"8px 8px\",\n            backgroundPosition: \"0 0, 0 4px, 4px -4px, -4px 0px\",\n          }}\n        >\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute inset-0\"\n            style={{\n              backgroundImage: `linear-gradient(to right, transparent, ${color})`,\n            }}\n          />\n          <SliderPrimitive.Indicator className=\"hidden\" />\n        </SliderPrimitive.Track>\n        <SliderPrimitive.Thumb\n          getAriaLabel={() => \"Colour opacity\"}\n          className=\"block size-4 shrink-0 rounded-full border-2 border-white bg-transparent shadow-md ring-1 ring-black/20 outline-none select-none focus-visible:ring-4 focus-visible:ring-ring/25\"\n        />\n      </SliderPrimitive.Control>\n    </SliderPrimitive.Root>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// ColorPicker\n// ---------------------------------------------------------------------------\n\n/** Minimal ambient type for the still-experimental EyeDropper API. */\ntype EyeDropperApi = { open: () => Promise<{ sRGBHex: string }> }\n\nfunction EyedropperButton({\n  onPick,\n}: {\n  onPick: (hex: string) => void\n}) {\n  const [supported, setSupported] = React.useState(false)\n\n  React.useEffect(() => {\n    setSupported(typeof window !== \"undefined\" && \"EyeDropper\" in window)\n  }, [])\n\n  if (!supported) return null\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      size=\"icon\"\n      aria-label=\"Pick colour from screen\"\n      onClick={async () => {\n        try {\n          const EyeDropperCtor = (\n            window as unknown as { EyeDropper: new () => EyeDropperApi }\n          ).EyeDropper\n          const result = await new EyeDropperCtor().open()\n          onPick(result.sRGBHex)\n        } catch {\n          // Cancelled by the user — no-op.\n        }\n      }}\n    >\n      <Dropper />\n    </Button>\n  )\n}\n\nfunction ColorPicker({\n  value,\n  defaultValue = \"#121541\",\n  onValueChange,\n  presets,\n  className,\n}: {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (hex: string) => void\n  /** Optional preset swatch row, rendered below the sliders. */\n  presets?: string[]\n  className?: string\n}) {\n  const isControlled = value !== undefined\n  const [internalHex, setInternalHex] = React.useState(defaultValue)\n  const currentHex = isControlled ? value : internalHex\n\n  const [hsva, setHsva] = React.useState<HSVA>(\n    () => hexToHsva(currentHex) ?? { h: 231, s: 0.72, v: 0.25, a: 1 }\n  )\n\n  // Keep local HSVA in sync when the committed hex changes from outside\n  // (controlled updates, preset clicks, hex field edits round-tripping).\n  const lastEmittedHex = React.useRef(currentHex)\n  React.useEffect(() => {\n    if (currentHex === lastEmittedHex.current) return\n    const parsed = hexToHsva(currentHex)\n    if (parsed) setHsva(parsed)\n    lastEmittedHex.current = currentHex\n  }, [currentHex])\n\n  const commit = (next: HSVA) => {\n    setHsva(next)\n    const hex = hsvaToHex(next)\n    lastEmittedHex.current = hex\n    if (!isControlled) setInternalHex(hex)\n    onValueChange?.(hex)\n  }\n\n  const [hexInput, setHexInput] = React.useState(currentHex)\n  React.useEffect(() => setHexInput(currentHex), [currentHex])\n\n  const rgb = hsvToRgb(hsva)\n  const solidHex = hsvaToHex({ ...hsva, a: 1 })\n\n  return (\n    <div\n      data-slot=\"color-picker\"\n      className={cn(\"flex w-72 flex-col gap-3\", className)}\n    >\n      <SaturationValueArea\n        hsva={hsva}\n        onChange={(sv) => commit({ ...hsva, ...sv })}\n      />\n\n      <div className=\"flex items-center gap-3\">\n        <ColorSwatch\n          color={hsvaToHex(hsva)}\n          size=\"md\"\n          aria-label=\"Current colour\"\n          className=\"pointer-events-none shrink-0\"\n        />\n        <div className=\"flex flex-1 flex-col gap-2\">\n          <HueSlider hue={hsva.h} onChange={(h) => commit({ ...hsva, h })} />\n          <AlphaSlider\n            alpha={hsva.a}\n            color={solidHex}\n            onChange={(a) => commit({ ...hsva, a })}\n          />\n        </div>\n        <EyedropperButton\n          onPick={(hex) => {\n            const parsed = hexToHsva(hex)\n            if (parsed) commit(parsed)\n          }}\n        />\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        <label className=\"flex flex-1 flex-col gap-1\">\n          <span className=\"text-xs font-semibold text-muted-foreground\">Hex</span>\n          <Input\n            value={hexInput}\n            onChange={(event) => setHexInput(event.target.value)}\n            onBlur={() => {\n              const parsed = hexToHsva(hexInput)\n              if (parsed) commit(parsed)\n              else setHexInput(currentHex)\n            }}\n            onKeyDown={(event) => {\n              if (event.key !== \"Enter\") return\n              const parsed = hexToHsva(hexInput)\n              if (parsed) commit(parsed)\n              event.currentTarget.blur()\n            }}\n            className=\"h-9 font-mono text-sm\"\n          />\n        </label>\n        {([\"r\", \"g\", \"b\"] as const).map((channel) => (\n          <label key={channel} className=\"flex w-14 flex-col gap-1\">\n            <span className=\"text-xs font-semibold text-muted-foreground uppercase\">\n              {channel}\n            </span>\n            <Input\n              inputMode=\"numeric\"\n              value={rgb[channel]}\n              onChange={(event) => {\n                const parsed = Number(event.target.value)\n                if (Number.isNaN(parsed)) return\n                const clamped = Math.min(Math.max(parsed, 0), 255)\n                const nextRgb = { ...rgb, [channel]: clamped }\n                commit(rgbaToHsva(nextRgb.r, nextRgb.g, nextRgb.b, hsva.a))\n              }}\n              className=\"h-9 px-2 text-center font-mono text-sm\"\n            />\n          </label>\n        ))}\n      </div>\n\n      {presets && presets.length > 0 ? (\n        <div className=\"flex flex-col gap-1.5\">\n          <span className=\"text-xs font-semibold text-muted-foreground\">Presets</span>\n          <div className=\"flex flex-wrap gap-2\">\n            {presets.map((preset) => (\n              <ColorSwatch\n                key={preset}\n                color={preset}\n                size=\"sm\"\n                selected={preset.toLowerCase() === solidHex.toLowerCase()}\n                onClick={() => {\n                  const parsed = hexToHsva(preset)\n                  if (parsed) commit(parsed)\n                }}\n              />\n            ))}\n          </div>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nfunction ColorPickerTrigger({\n  value,\n  className,\n  ...props\n}: React.ComponentProps<typeof Button> & { value: string }) {\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      className={cn(\"gap-2 pl-2\", className)}\n      {...props}\n    >\n      {/* A span, not ColorSwatch — the swatch renders a <button>, which may\n          not nest inside the trigger button. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"relative size-6 shrink-0 overflow-hidden rounded-lg border border-border shadow-xs\"\n        style={{\n          backgroundImage:\n            \"linear-gradient(45deg, var(--color-border) 25%, transparent 25%), linear-gradient(-45deg, var(--color-border) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--color-border) 75%), linear-gradient(-45deg, transparent 75%, var(--color-border) 75%)\",\n          backgroundSize: \"8px 8px\",\n          backgroundPosition: \"0 0, 0 4px, 4px -4px, -4px 0px\",\n        }}\n      >\n        <span\n          className=\"absolute inset-0\"\n          style={{ backgroundColor: value }}\n        />\n      </span>\n      <span className=\"font-mono text-sm uppercase\">{value}</span>\n    </Button>\n  )\n}\n\nexport {\n  ColorPicker,\n  ColorPickerTrigger,\n  Popover as ColorPickerPopover,\n  PopoverContent as ColorPickerPopoverContent,\n  PopoverTrigger as ColorPickerPopoverTrigger,\n  hexToHsva,\n  hsvaToHex,\n  hsvToRgb,\n  rgbToHsv,\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}