{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "title": "ColorPicker",
  "description": "Swatch-and-hex trigger opening an HSV field, hue rail, hex input, and preset swatches.",
  "dependencies": [
    "@base-ui/react",
    "@hugeicons/core-free-icons",
    "@hugeicons/react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/neon-tokens.json"
  ],
  "files": [
    {
      "path": "src/components/color-picker/color-picker.tsx",
      "content": "\"use client\";\n\n/* oxlint-disable jsx-a11y/prefer-tag-over-role -- the 2D saturation/brightness plane and styled hue rail have no native input equivalent; both implement the full slider keyboard contract */\n\nimport { Popover } from \"@base-ui/react/popover\";\nimport {\n  ColorPickerIcon,\n  Copy01Icon,\n  Tick02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type { CSSProperties, KeyboardEvent, PointerEvent } from \"react\";\nimport { useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface ColorPickerProps {\n  /** Controlled hex value, e.g. \"#00e599\". */\n  value?: string;\n  /** Uncontrolled initial hex value. */\n  defaultValue?: string;\n  onValueChange?: (hex: string) => void;\n  /** Preset swatches rendered under the field. */\n  swatches?: string[];\n  disabled?: boolean;\n  className?: string;\n  /** Accessible label for the trigger. */\n  label?: string;\n}\n\n/* ─────────────────────────────────────────────────────────\n * COLOR MATH — HSV internally, hex at the edges. Hue is\n * kept in state (not re-derived) so the rail doesn't snap\n * to 0 when saturation or value hit their extremes.\n * ───────────────────────────────────────────────────────── */\nexport interface Hsv {\n  h: number;\n  s: number;\n  v: number;\n}\n\nconst HEX_RE = /^#?(?<hex>[0-9a-f]{6})$/iu;\n\nconst clamp01 = (x: number) => Math.min(1, Math.max(0, x));\n\nexport const hexToHsv = (hex: string): Hsv | null => {\n  const match = HEX_RE.exec(hex.trim());\n\n  if (!match?.groups?.hex) {\n    return null;\n  }\n\n  const int = Number.parseInt(match.groups.hex, 16);\n  const r = Math.floor(int / 65_536) / 255;\n  const g = Math.floor((int % 65_536) / 256) / 255;\n  const b = (int % 256) / 255;\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  const delta = max - min;\n  let h = 0;\n\n  if (delta > 0) {\n    if (max === r) {\n      h = 60 * (((g - b) / delta) % 6);\n    } else if (max === g) {\n      h = 60 * ((b - r) / delta + 2);\n    } else {\n      h = 60 * ((r - g) / delta + 4);\n    }\n  }\n\n  return {\n    h: (h + 360) % 360,\n    s: max === 0 ? 0 : delta / max,\n    v: max,\n  };\n};\n\nexport const hsvToHex = ({ h, s, v }: Hsv): string => {\n  const f = (n: number) => {\n    const k = (n + h / 60) % 6;\n    const channel = v - v * s * Math.max(0, Math.min(k, 4 - k, 1));\n    return Math.round(channel * 255)\n      .toString(16)\n      .padStart(2, \"0\");\n  };\n\n  return `#${f(5)}${f(3)}${f(1)}`;\n};\n\n/**\n * Shared square thumb: filled with the color it points at, white ring,\n * transform-centered. Swells slightly while its plane is being dragged so\n * the hand feels the grab; settles back on release.\n */\nconst Thumb = ({\n  dragging,\n  style,\n}: {\n  dragging?: boolean;\n  style: CSSProperties;\n}) => (\n  <span\n    aria-hidden=\"true\"\n    className={cn(\n      \"pointer-events-none absolute size-3 origin-center border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.6)] transition-transform duration-150 motion-reduce:transition-none\",\n      dragging\n        ? \"-translate-x-1/2 -translate-y-1/2 scale-125\"\n        : \"-translate-x-1/2 -translate-y-1/2\"\n    )}\n    style={style}\n  />\n);\n\ninterface EyeDropperResult {\n  sRGBHex: string;\n}\n\ntype EyeDropperConstructor = new () => {\n  open: () => Promise<EyeDropperResult>;\n};\n\nconst getEyeDropper = (): EyeDropperConstructor | null => {\n  if (typeof window === \"undefined\") {\n    return null;\n  }\n\n  const candidate = (window as { EyeDropper?: EyeDropperConstructor })\n    .EyeDropper;\n  return candidate ?? null;\n};\n\n/** Turn a pointer event into 0-1 coordinates within the target. */\nconst fraction = (event: PointerEvent<HTMLDivElement>) => {\n  const rect = event.currentTarget.getBoundingClientRect();\n  return {\n    x: clamp01((event.clientX - rect.left) / rect.width),\n    y: clamp01((event.clientY - rect.top) / rect.height),\n  };\n};\n\nconst arrowDelta = (key: string): [number, number] | null => {\n  switch (key) {\n    case \"ArrowUp\": {\n      return [0, -1];\n    }\n    case \"ArrowDown\": {\n      return [0, 1];\n    }\n    case \"ArrowLeft\": {\n      return [-1, 0];\n    }\n    case \"ArrowRight\": {\n      return [1, 0];\n    }\n    default: {\n      return null;\n    }\n  }\n};\n\nexport const ColorPicker = ({\n  className,\n  defaultValue = \"#00e599\",\n  disabled = false,\n  label = \"Color\",\n  onValueChange,\n  swatches,\n  value,\n  ...props\n}: ColorPickerProps) => {\n  const [hsv, setHsv] = useState<Hsv>(\n    () => hexToHsv(defaultValue) ?? { h: 160, s: 1, v: 0.9 }\n  );\n  // Hex input draft, only while the field is being edited.\n  const [draft, setDraft] = useState<string | null>(null);\n  // Controlled value changes re-seed HSV during render (no effect needed).\n  const [seenValue, setSeenValue] = useState(value);\n\n  if (value !== seenValue) {\n    setSeenValue(value);\n    const parsed = value ? hexToHsv(value) : null;\n\n    if (parsed) {\n      setHsv(parsed);\n    }\n  }\n\n  const hex = (value ?? hsvToHex(hsv)).toLowerCase();\n  const [dragging, setDragging] = useState<\"field\" | \"hue\" | null>(null);\n  const [copied, setCopied] = useState(false);\n  const eyeDropper = getEyeDropper();\n\n  const commit = (next: Hsv) => {\n    setHsv(next);\n    setDraft(null);\n    onValueChange?.(hsvToHex(next));\n  };\n\n  const commitHex = (candidate: string) => {\n    const parsed = hexToHsv(candidate);\n\n    if (parsed) {\n      commit(parsed);\n    } else {\n      setDraft(null);\n    }\n  };\n\n  const pickFromScreen = async () => {\n    if (!eyeDropper) {\n      return;\n    }\n\n    try {\n      const result = await new eyeDropper().open();\n      commitHex(result.sRGBHex);\n    } catch {\n      // Dismissed the eyedropper; keep the current color.\n    }\n  };\n\n  const copyHex = async () => {\n    await navigator.clipboard.writeText(hex);\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1200);\n  };\n\n  const handleFieldPointer = (event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setDragging(\"field\");\n    const { x, y } = fraction(event);\n    commit({ ...hsv, s: x, v: 1 - y });\n  };\n\n  const handleFieldKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    const delta = arrowDelta(event.key);\n\n    if (!delta) {\n      return;\n    }\n\n    event.preventDefault();\n    const step = event.shiftKey ? 0.1 : 0.02;\n    commit({\n      ...hsv,\n      s: clamp01(hsv.s + delta[0] * step),\n      v: clamp01(hsv.v - delta[1] * step),\n    });\n  };\n\n  const handleHuePointer = (event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setDragging(\"hue\");\n    commit({ ...hsv, h: fraction(event).x * 360 });\n  };\n\n  const handleHueKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    const delta = arrowDelta(event.key);\n\n    if (!delta) {\n      return;\n    }\n\n    event.preventDefault();\n    const step = event.shiftKey ? 30 : 4;\n    commit({ ...hsv, h: (hsv.h + delta[0] * step + 360) % 360 });\n  };\n\n  const hueOnly = hsvToHex({ h: hsv.h, s: 1, v: 1 });\n\n  return (\n    <Popover.Root>\n      <Popover.Trigger\n        aria-label={`${label}: ${hex}`}\n        className={cn(\n          \"inline-flex h-8 cursor-pointer select-none items-center gap-2 rounded-md border border-border/60 bg-card px-2 font-mono text-foreground text-xs shadow-none ring-0 transition-colors hover:border-border focus-visible:border-primary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        data-slot=\"color-picker\"\n        disabled={disabled}\n        {...props}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"size-4 shrink-0 border border-border/60 transition-[background-color] duration-150\"\n          style={{ backgroundColor: hex }}\n        />\n        {hex}\n      </Popover.Trigger>\n      <Popover.Portal>\n        <Popover.Positioner align=\"start\" side=\"bottom\" sideOffset={4}>\n          <Popover.Popup\n            className=\"z-50 w-56 rounded-md bg-popover p-3 text-popover-foreground shadow-none outline-none ring-1 ring-border/60 duration-100 data-closed:animate-out data-closed:fade-out-0 data-open:animate-in data-open:fade-in-0\"\n            data-slot=\"color-picker-popup\"\n          >\n            <div\n              aria-label=\"Saturation and brightness\"\n              aria-valuemax={100}\n              aria-valuemin={0}\n              aria-valuenow={Math.round(hsv.v * 100)}\n              aria-valuetext={hex}\n              className=\"relative h-36 cursor-crosshair touch-none focus-visible:outline focus-visible:outline-primary\"\n              data-slot=\"color-picker-field\"\n              onKeyDown={handleFieldKey}\n              onPointerDown={handleFieldPointer}\n              onPointerMove={(event) => {\n                if (event.buttons > 0) {\n                  handleFieldPointer(event);\n                }\n              }}\n              onPointerUp={() => setDragging(null)}\n              role=\"slider\"\n              style={{\n                background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), ${hueOnly}`,\n              }}\n              tabIndex={disabled ? -1 : 0}\n            >\n              <Thumb\n                dragging={dragging === \"field\"}\n                style={{\n                  backgroundColor: hex,\n                  left: `${hsv.s * 100}%`,\n                  top: `${(1 - hsv.v) * 100}%`,\n                }}\n              />\n            </div>\n\n            <div\n              aria-label=\"Hue\"\n              aria-valuemax={360}\n              aria-valuemin={0}\n              aria-valuenow={Math.round(hsv.h)}\n              className=\"relative mt-3 h-3 cursor-ew-resize touch-none focus-visible:outline focus-visible:outline-primary\"\n              data-slot=\"color-picker-hue\"\n              onKeyDown={handleHueKey}\n              onPointerDown={handleHuePointer}\n              onPointerMove={(event) => {\n                if (event.buttons > 0) {\n                  handleHuePointer(event);\n                }\n              }}\n              onPointerUp={() => setDragging(null)}\n              role=\"slider\"\n              style={{\n                background:\n                  \"linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)\",\n              }}\n              tabIndex={disabled ? -1 : 0}\n            >\n              <Thumb\n                dragging={dragging === \"hue\"}\n                style={{\n                  backgroundColor: hueOnly,\n                  left: `${(hsv.h / 360) * 100}%`,\n                  top: \"50%\",\n                }}\n              />\n            </div>\n\n            <div className=\"mt-3 flex items-center gap-1.5\">\n              <span className=\"font-mono text-muted-foreground text-xs\">#</span>\n              <input\n                aria-label=\"Hex value\"\n                className=\"h-7 w-full min-w-0 border border-border/60 bg-transparent px-1.5 font-mono text-base outline-none transition-colors focus:border-primary sm:text-xs\"\n                onBlur={() => commitHex(draft ?? hex)}\n                onChange={(event) => setDraft(event.target.value)}\n                onKeyDown={(event) => {\n                  if (event.key === \"Enter\") {\n                    commitHex(draft ?? hex);\n                  }\n                }}\n                spellCheck={false}\n                value={(draft ?? hex).replace(\"#\", \"\")}\n              />\n              <button\n                aria-label={copied ? \"Copied\" : \"Copy hex\"}\n                className=\"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-border/60 text-muted-foreground transition-colors hover:border-border hover:text-foreground focus-visible:border-primary focus-visible:outline-none\"\n                onClick={copyHex}\n                type=\"button\"\n              >\n                {copied ? (\n                  <HugeiconsIcon\n                    aria-hidden=\"true\"\n                    className=\"size-3 text-primary\"\n                    icon={Tick02Icon}\n                    strokeWidth={2}\n                  />\n                ) : (\n                  <HugeiconsIcon\n                    aria-hidden=\"true\"\n                    className=\"size-3\"\n                    icon={Copy01Icon}\n                    strokeWidth={2}\n                  />\n                )}\n              </button>\n              {eyeDropper ? (\n                <button\n                  aria-label=\"Pick a color from the screen\"\n                  className=\"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-border/60 text-muted-foreground transition-colors hover:border-border hover:text-foreground focus-visible:border-primary focus-visible:outline-none\"\n                  onClick={pickFromScreen}\n                  type=\"button\"\n                >\n                  <HugeiconsIcon\n                    aria-hidden=\"true\"\n                    className=\"size-3\"\n                    icon={ColorPickerIcon}\n                    strokeWidth={2}\n                  />\n                </button>\n              ) : null}\n            </div>\n\n            {swatches?.length ? (\n              <div className=\"mt-3 flex flex-wrap gap-1.5\">\n                {swatches.map((swatch) => (\n                  <button\n                    aria-label={`Use ${swatch}`}\n                    aria-pressed={swatch.toLowerCase() === hex}\n                    className={cn(\n                      \"size-5 cursor-pointer rounded-[2px] border transition-[border-color,transform] duration-150 hover:scale-110 hover:border-foreground focus-visible:border-primary focus-visible:outline-none motion-reduce:transition-none motion-reduce:hover:scale-100\",\n                      swatch.toLowerCase() === hex\n                        ? \"border-foreground shadow-[0_0_0_1px_var(--background)_inset]\"\n                        : \"border-border/60\"\n                    )}\n                    key={swatch}\n                    onClick={() => commitHex(swatch)}\n                    style={{ backgroundColor: swatch }}\n                    type=\"button\"\n                  />\n                ))}\n              </div>\n            ) : null}\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n};\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}