{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "type": "registry:ui",
  "title": "Color Picker",
  "description": "An accessible colour picker: 2D saturation area, hue and alpha sliders, validated hex / RGB / HSL input, swatches and eyedropper.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/color-picker.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* -------------------------------------------------------------------------- */\n/*  Colour math (sRGB, no dependencies)                                        */\n/* -------------------------------------------------------------------------- */\n\n/** Hue 0–360, saturation / value / alpha 0–1. HSV keeps the hue when a colour turns grey. */\nexport interface Hsva {\n  h: number;\n  s: number;\n  v: number;\n  a: number;\n}\ntype Rgb = [number, number, number];\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\n\nfunction hsvToRgb({ h, s, v }: Hsva): Rgb {\n  const f = (n: number) => {\n    const k = (n + h / 60) % 6;\n    return (v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255;\n  };\n  return [f(5), f(3), f(1)];\n}\n\nfunction hueOf(r: number, g: number, b: number, max: number, d: number) {\n  if (d === 0) return null;\n  let h: number;\n  if (max === r) h = ((g - b) / d) % 6;\n  else if (max === g) h = (b - r) / d + 2;\n  else h = (r - g) / d + 4;\n  return (h * 60 + 360) % 360;\n}\n\n/** RGB (0–255) to HSV; hue / saturation are taken from `prev` when they are undefined (greys, black). */\nfunction rgbToHsva([r, g, b]: Rgb, a: number, prev?: Hsva): Hsva {\n  const [rn, gn, bn] = [r / 255, g / 255, b / 255];\n  const max = Math.max(rn, gn, bn);\n  const d = max - Math.min(rn, gn, bn);\n  const h = hueOf(rn, gn, bn, max, d);\n  return {\n    h: h ?? prev?.h ?? 0,\n    s: max === 0 ? (prev?.s ?? 0) : d / max,\n    v: max,\n    a: clamp(a, 0, 1),\n  };\n}\n\nfunction rgbToHsl([r, g, b]: Rgb, fallbackHue: number): [number, number, number] {\n  const [rn, gn, bn] = [r / 255, g / 255, b / 255];\n  const max = Math.max(rn, gn, bn);\n  const min = Math.min(rn, gn, bn);\n  const d = max - min;\n  const l = (max + min) / 2;\n  const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));\n  return [hueOf(rn, gn, bn, max, d) ?? fallbackHue, s, l];\n}\n\nfunction hslToRgb(h: number, s: number, l: number): Rgb {\n  const a = s * Math.min(l, 1 - l);\n  const f = (n: number) => {\n    const k = (n + h / 30) % 12;\n    return (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255;\n  };\n  return [f(0), f(8), f(4)];\n}\n\nconst hex2 = (n: number) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, \"0\").toUpperCase();\n\n/** \"#RRGGBB\", or \"#RRGGBBAA\" when the colour is not fully opaque. */\nexport function toHex(c: Hsva) {\n  const [r, g, b] = hsvToRgb(c);\n  return `#${hex2(r)}${hex2(g)}${hex2(b)}${c.a < 1 ? hex2(c.a * 255) : \"\"}`;\n}\n\n/** Parses one channel: plain numbers are 0–`scale`, percentages map to 0–`scale`. */\nfunction channel(token: string, scale: number) {\n  const pct = token.endsWith(\"%\");\n  const n = parseFloat(token);\n  if (!Number.isFinite(n)) return NaN;\n  return pct ? (n / 100) * scale : n;\n}\n\n/**\n * Parses hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba() and hsl()/hsla() — comma or\n * space separated — into HSVA. Returns null for anything else.\n */\nexport function parseColor(input: string, prev?: Hsva): Hsva | null {\n  const text = input.trim().toLowerCase();\n  const hex = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text);\n  if (hex) {\n    let digits = hex[1];\n    if (digits.length <= 4) digits = [...digits].map((d) => d + d).join(\"\");\n    const n = (i: number) => parseInt(digits.slice(i, i + 2), 16);\n    return rgbToHsva([n(0), n(2), n(4)], digits.length === 8 ? n(6) / 255 : 1, prev);\n  }\n  const fn = /^(rgba?|hsla?)\\(([^)]*)\\)$/.exec(text);\n  if (!fn) return null;\n  const parts = fn[2].split(/[\\s,/]+/).filter(Boolean);\n  if (parts.length !== 3 && parts.length !== 4) return null;\n  const alpha = parts[3] === undefined ? 1 : channel(parts[3], 1);\n  if (fn[1].startsWith(\"rgb\")) {\n    const rgb = parts.slice(0, 3).map((p) => channel(p, 255));\n    if ([...rgb, alpha].some((n) => !Number.isFinite(n) || n < 0) || rgb.some((n) => n > 255) || alpha > 1) return null;\n    return rgbToHsva(rgb as Rgb, alpha, prev);\n  }\n  const h = parseFloat(parts[0]);\n  const s = channel(parts[1].endsWith(\"%\") ? parts[1] : `${parts[1]}%`, 1);\n  const l = channel(parts[2].endsWith(\"%\") ? parts[2] : `${parts[2]}%`, 1);\n  if (![h, s, l, alpha].every(Number.isFinite) || s < 0 || s > 1 || l < 0 || l > 1 || alpha < 0 || alpha > 1) return null;\n  const hue = ((h % 360) + 360) % 360;\n  const parsed = rgbToHsva(hslToRgb(hue, s, l), alpha, prev);\n  // Keep the typed hue even for greys.\n  return s === 0 || l === 0 || l === 1 ? { ...parsed, h: hue } : parsed;\n}\n\nexport type ColorFormat = \"hex\" | \"rgb\" | \"hsl\";\n\nconst round = (n: number, digits = 0) => Number(n.toFixed(digits));\n\nfunction formatColor(c: Hsva, format: ColorFormat, withAlpha: boolean) {\n  const a = withAlpha ? round(c.a, 2) : 1;\n  if (format === \"hex\") return toHex({ ...c, a });\n  const rgb = hsvToRgb(c);\n  if (format === \"rgb\") {\n    const [r, g, b] = rgb.map((n) => Math.round(n));\n    return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;\n  }\n  const [h, s, l] = rgbToHsl(rgb, c.h);\n  const body = `${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%`;\n  return a < 1 ? `hsla(${body}, ${a})` : `hsl(${body})`;\n}\n\nconst HUE_NAMES: [number, string][] = [\n  [10, \"red\"],\n  [40, \"orange\"],\n  [65, \"yellow\"],\n  [90, \"lime\"],\n  [155, \"green\"],\n  [185, \"teal\"],\n  [205, \"cyan\"],\n  [245, \"blue\"],\n  [280, \"violet\"],\n  [320, \"magenta\"],\n  [345, \"pink\"],\n  [361, \"red\"],\n];\nconst hueName = (h: number) => HUE_NAMES.find(([limit]) => h < limit)?.[1] ?? \"red\";\n\n/* -------------------------------------------------------------------------- */\n/*  Browser features                                                           */\n/* -------------------------------------------------------------------------- */\n\ninterface EyeDropperInstance {\n  open: () => Promise<{ sRGBHex: string }>;\n}\ntype EyeDropperConstructor = new () => EyeDropperInstance;\n\nconst noopSubscribe = () => () => {};\n/** False on the server and during hydration, so markup stays deterministic. */\nfunction useEyeDropperSupport() {\n  return React.useSyncExternalStore(noopSubscribe, () => \"EyeDropper\" in window, () => false);\n}\n\n/**\n * Collapses pointer moves into one update per animation frame, so dragging never sets state\n * more often than the screen refreshes.\n */\nfunction useFrameThrottle<T>(apply: (value: T) => void) {\n  const frame = React.useRef<number | null>(null);\n  const pending = React.useRef<T | null>(null);\n  const applyRef = React.useRef(apply);\n  React.useEffect(() => {\n    applyRef.current = apply;\n  });\n  React.useEffect(\n    () => () => {\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n  return (value: T) => {\n    pending.current = value;\n    if (frame.current !== null) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = null;\n      if (pending.current !== null) applyRef.current(pending.current);\n      pending.current = null;\n    });\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Channel slider (hue / alpha)                                               */\n/* -------------------------------------------------------------------------- */\n\ninterface ChannelSliderProps {\n  label: string;\n  value: number;\n  max: number;\n  valueText: string;\n  background: string;\n  thumbColor: string;\n  checker?: boolean;\n  disabled: boolean;\n  onChange: (value: number) => void;\n  onCommit: () => void;\n}\n\nfunction ChannelSlider({ label, value, max, valueText, background, thumbColor, checker, disabled, onChange, onCommit }: ChannelSliderProps) {\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const dragging = React.useRef(false);\n  const fromPointer = (clientX: number) => {\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0) return;\n    onChange(clamp((clientX - rect.left) / rect.width, 0, 1) * max);\n  };\n  const throttled = useFrameThrottle(fromPointer);\n  const end = () => {\n    if (!dragging.current) return;\n    dragging.current = false;\n    onCommit();\n  };\n\n  return (\n    <div\n      ref={trackRef}\n      className={`relative h-3 touch-none rounded-full ${disabled ? \"cursor-not-allowed\" : \"cursor-pointer\"}`}\n      onPointerDown={(event) => {\n        if (disabled || event.button !== 0) return;\n        event.preventDefault();\n        event.currentTarget.setPointerCapture(event.pointerId);\n        (event.currentTarget.querySelector('[role=\"slider\"]') as HTMLElement | null)?.focus({ preventScroll: true });\n        dragging.current = true;\n        fromPointer(event.clientX);\n      }}\n      onPointerMove={(event) => {\n        if (dragging.current) throttled(event.clientX);\n      }}\n      onPointerUp={end}\n      onPointerCancel={end}\n      onLostPointerCapture={end}\n    >\n      {checker && <span aria-hidden=\"true\" className=\"absolute inset-0 rounded-full bg-[repeating-conic-gradient(#d4d4d8_0%_25%,#fff_0%_50%)] bg-[length:8px_8px] dark:bg-[repeating-conic-gradient(#3f3f46_0%_25%,#27272a_0%_50%)]\" />}\n      <span aria-hidden=\"true\" className=\"absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.1)]\" style={{ background }} />\n      <div\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={max}\n        aria-valuenow={Math.round(value)}\n        aria-valuetext={valueText}\n        aria-orientation=\"horizontal\"\n        aria-disabled={disabled || undefined}\n        onKeyDown={(event) => {\n          if (disabled) return;\n          const big = max / 10;\n          const delta: Record<string, number> = {\n            ArrowRight: event.shiftKey ? big : 1,\n            ArrowUp: event.shiftKey ? big : 1,\n            ArrowLeft: event.shiftKey ? -big : -1,\n            ArrowDown: event.shiftKey ? -big : -1,\n            PageUp: big,\n            PageDown: -big,\n            Home: -max,\n            End: max,\n          };\n          if (!(event.key in delta)) return;\n          event.preventDefault();\n          onChange(clamp(Math.round(value) + delta[event.key], 0, max));\n          onCommit();\n        }}\n        className=\"absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.25),0_1px_3px_rgb(0_0_0/0.3)] outline-none ring-[var(--cp-accent)] ring-offset-2 ring-offset-white focus-visible:ring-2 dark:ring-offset-[#131619]\"\n        style={{ left: `calc(8px + (100% - 16px) * ${value / max})`, background: thumbColor }}\n      />\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Color picker                                                               */\n/* -------------------------------------------------------------------------- */\n\nexport interface ColorPickerProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  /** Accessible name of the whole picker (and prefix for its parts). */\n  label?: string;\n  /** Current colour as hex (controlled). Any format parseColor accepts is read. */\n  value?: string;\n  /** Initial colour (uncontrolled). */\n  defaultValue?: string;\n  /** Called with \"#RRGGBB\" (or \"#RRGGBBAA\" when alpha < 1) on every change. */\n  onValueChange?: (hex: string) => void;\n  /** Called when a drag ends and after keyboard, text, swatch and eyedropper changes. */\n  onValueCommit?: (hex: string) => void;\n  /** Show an opacity slider and allow 8-digit hex output. */\n  alpha?: boolean;\n  /** Format of the text field (users can switch it). */\n  format?: ColorFormat;\n  /** Show the text field and format switcher. */\n  showInput?: boolean;\n  /** Preset colours shown as buttons. Pass [] to hide. */\n  swatches?: string[];\n  /** Show an eyedropper button where the EyeDropper API exists (Chromium desktop). */\n  eyeDropper?: boolean;\n  /** Height of the saturation / brightness area in px. */\n  areaHeight?: number;\n  /** Focus ring and active swatch colour. */\n  accent?: string;\n  disabled?: boolean;\n  /** Submits the hex value with a form under this name. */\n  name?: string;\n}\n\nconst DEFAULT_SWATCHES = [\"#FF7448\", \"#B6F3D1\", \"#F5B83D\", \"#E5484D\", \"#2FBF71\", \"#3B82F6\", \"#18181B\", \"#F5F3EE\"];\nconst FALLBACK: Hsva = { h: 15, s: 0.72, v: 1, a: 1 };\n\n/**\n * An accessible colour picker: a 2D saturation / brightness area, hue and optional alpha sliders,\n * a validated hex / RGB / HSL field, swatches and the native eyedropper where available. Colour is\n * kept as HSVA internally so the hue survives greys; all conversions are inline.\n */\nexport function ColorPicker({\n  label = \"Colour\",\n  value: valueProp,\n  defaultValue = \"#FF7448\",\n  onValueChange,\n  onValueCommit,\n  alpha = false,\n  format = \"hex\",\n  showInput = true,\n  swatches = DEFAULT_SWATCHES,\n  eyeDropper = true,\n  areaHeight = 168,\n  accent = \"#FF7448\",\n  disabled = false,\n  name,\n  className = \"\",\n  style,\n  ...props\n}: ColorPickerProps) {\n  const id = React.useId();\n  const errorId = `${id}-error`;\n  const canEyeDrop = useEyeDropperSupport() && eyeDropper;\n\n  const [color, setColor] = React.useState<Hsva>(() => parseColor(valueProp ?? defaultValue) ?? FALLBACK);\n  // Controlled: re-sync when the parent passes a different colour (keeping our hue for greys).\n  const [syncedValue, setSyncedValue] = React.useState(valueProp);\n  if (valueProp !== syncedValue) {\n    setSyncedValue(valueProp);\n    const parsed = valueProp === undefined ? null : parseColor(valueProp, color);\n    if (parsed && toHex(parsed) !== toHex(color)) setColor(parsed);\n  }\n\n  const [fmt, setFmt] = React.useState<ColorFormat>(format);\n  const [syncedFormat, setSyncedFormat] = React.useState(format);\n  if (format !== syncedFormat) {\n    setSyncedFormat(format);\n    setFmt(format);\n  }\n\n  const [draft, setDraft] = React.useState<string | null>(null);\n  const areaRef = React.useRef<HTMLDivElement>(null);\n  const areaThumbRef = React.useRef<HTMLDivElement>(null);\n  const areaDragging = React.useRef(false);\n  const latest = React.useRef(color);\n\n  const effective: Hsva = alpha ? color : { ...color, a: 1 };\n  const hex = toHex(effective);\n  const opaqueHex = toHex({ ...color, a: 1 });\n  const pureHue = toHex({ h: color.h, s: 1, v: 1, a: 1 });\n\n  // Handlers read the latest colour from a ref so rAF-batched drags never use a stale value.\n  React.useEffect(() => {\n    latest.current = color;\n  }, [color]);\n\n  const update = (next: Hsva) => {\n    const normalised = alpha ? next : { ...next, a: 1 };\n    const previous = latest.current;\n    latest.current = normalised;\n    if (normalised.h === previous.h && normalised.s === previous.s && normalised.v === previous.v && normalised.a === previous.a) return;\n    setColor(normalised);\n    const nextHex = toHex(normalised);\n    if (nextHex !== toHex(alpha ? previous : { ...previous, a: 1 })) onValueChange?.(nextHex);\n  };\n  const commit = () => onValueCommit?.(toHex(latest.current));\n\n  /* ------------------------------ 2D area input ------------------------------ */\n\n  const areaFromPointer = ([clientX, clientY]: [number, number]) => {\n    const rect = areaRef.current?.getBoundingClientRect();\n    if (!rect || !rect.width || !rect.height) return;\n    update({\n      ...latest.current,\n      s: clamp((clientX - rect.left) / rect.width, 0, 1),\n      v: 1 - clamp((clientY - rect.top) / rect.height, 0, 1),\n    });\n  };\n  const throttledArea = useFrameThrottle(areaFromPointer);\n  const endAreaDrag = () => {\n    if (!areaDragging.current) return;\n    areaDragging.current = false;\n    commit();\n  };\n\n  const handleAreaKey = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const unit = event.shiftKey ? 0.1 : 0.01;\n    let { s, v } = color;\n    switch (event.key) {\n      case \"ArrowRight\":\n        s += unit;\n        break;\n      case \"ArrowLeft\":\n        s -= unit;\n        break;\n      case \"ArrowUp\":\n        v += unit;\n        break;\n      case \"ArrowDown\":\n        v -= unit;\n        break;\n      case \"PageUp\":\n        v += 0.1;\n        break;\n      case \"PageDown\":\n        v -= 0.1;\n        break;\n      case \"Home\":\n        s = 0;\n        break;\n      case \"End\":\n        s = 1;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    // Round to whole percentages so keyboard steps land on clean values.\n    update({ ...color, s: clamp(Math.round(s * 100) / 100, 0, 1), v: clamp(Math.round(v * 100) / 100, 0, 1) });\n    commit();\n  };\n\n  /* ------------------------------- Text field -------------------------------- */\n\n  const formatted = formatColor(effective, fmt, alpha);\n  const draftValid = draft === null || parseColor(draft, color) !== null;\n\n  const applyText = (text: string) => {\n    const parsed = parseColor(text, color);\n    if (!parsed) return false;\n    update(parsed);\n    return true;\n  };\n\n  const pickWithEyeDropper = async () => {\n    const Ctor = (window as unknown as { EyeDropper?: EyeDropperConstructor }).EyeDropper;\n    if (!Ctor) return;\n    try {\n      const result = await new Ctor().open();\n      const parsed = parseColor(result.sRGBHex, color);\n      if (parsed) {\n        update({ ...parsed, a: color.a });\n        commit();\n      }\n    } catch {\n      // The user pressed Escape: nothing to do.\n    }\n  };\n\n  const sPct = Math.round(color.s * 100);\n  const vPct = Math.round(color.v * 100);\n  const checker =\n    \"bg-[repeating-conic-gradient(#d4d4d8_0%_25%,#fff_0%_50%)] bg-[length:8px_8px] dark:bg-[repeating-conic-gradient(#3f3f46_0%_25%,#27272a_0%_50%)]\";\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={`flex w-full max-w-[18rem] select-none flex-col gap-3 ${disabled ? \"pointer-events-none opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--cp-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {/* Saturation (x) / brightness (y) area. */}\n      <div\n        ref={areaRef}\n        className=\"relative touch-none rounded-lg shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08)]\"\n        style={{\n          height: areaHeight,\n          backgroundColor: pureHue,\n          backgroundImage: \"linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)\",\n          cursor: disabled ? \"not-allowed\" : \"crosshair\",\n        }}\n        onPointerDown={(event) => {\n          if (disabled || event.button !== 0) return;\n          event.preventDefault();\n          event.currentTarget.setPointerCapture(event.pointerId);\n          areaThumbRef.current?.focus({ preventScroll: true });\n          areaDragging.current = true;\n          areaFromPointer([event.clientX, event.clientY]);\n        }}\n        onPointerMove={(event) => {\n          if (areaDragging.current) throttledArea([event.clientX, event.clientY]);\n        }}\n        onPointerUp={endAreaDrag}\n        onPointerCancel={endAreaDrag}\n        onLostPointerCapture={endAreaDrag}\n      >\n        <div\n          ref={areaThumbRef}\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-label={`${label}: saturation and brightness`}\n          aria-roledescription=\"2D slider\"\n          aria-valuemin={0}\n          aria-valuemax={100}\n          aria-valuenow={sPct}\n          aria-valuetext={`Saturation ${sPct}%, brightness ${vPct}%`}\n          aria-describedby={`${id}-area-help`}\n          aria-disabled={disabled || undefined}\n          onKeyDown={handleAreaKey}\n          className=\"absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.3),0_2px_6px_rgb(0_0_0/0.35)] outline-none ring-[var(--cp-accent)] ring-offset-2 ring-offset-white focus-visible:ring-2 dark:ring-offset-[#131619]\"\n          style={{ left: `${color.s * 100}%`, top: `${(1 - color.v) * 100}%`, background: opaqueHex }}\n        />\n        <span id={`${id}-area-help`} className=\"sr-only\">\n          Left and right arrows change saturation, up and down arrows change brightness. Hold Shift for larger steps.\n        </span>\n      </div>\n\n      <div className=\"flex items-center gap-3\">\n        {/* Live preview chip (decorative: the text field and sliders carry the value). */}\n        <span aria-hidden=\"true\" className={`relative size-9 shrink-0 overflow-hidden rounded-full ${checker}`}>\n          <span className=\"absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.14)]\" style={{ background: hex }} />\n        </span>\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2.5\">\n          <ChannelSlider\n            label={`${label}: hue`}\n            value={color.h}\n            max={360}\n            valueText={`${Math.round(color.h)}°, ${hueName(color.h)}`}\n            background=\"linear-gradient(to right, #f00 0%, #ff0 16.67%, #0f0 33.33%, #0ff 50%, #00f 66.67%, #f0f 83.33%, #f00 100%)\"\n            thumbColor={pureHue}\n            disabled={disabled}\n            onChange={(h) => update({ ...latest.current, h: clamp(h, 0, 360) })}\n            onCommit={commit}\n          />\n          {alpha && (\n            <ChannelSlider\n              label={`${label}: opacity`}\n              value={color.a * 100}\n              max={100}\n              valueText={`${Math.round(color.a * 100)}% opacity`}\n              background={`linear-gradient(to right, transparent, ${opaqueHex})`}\n              thumbColor={hex}\n              checker\n              disabled={disabled}\n              onChange={(a) => update({ ...latest.current, a: clamp(a / 100, 0, 1) })}\n              onCommit={commit}\n            />\n          )}\n        </div>\n        {canEyeDrop && (\n          <button\n            type=\"button\"\n            onClick={pickWithEyeDropper}\n            aria-label=\"Pick a colour from the screen\"\n            className=\"inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-zinc-900/10 text-zinc-600 transition-colors hover:bg-zinc-900/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cp-accent)] dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/5 dark:hover:text-white\"\n          >\n            <svg viewBox=\"0 0 24 24\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n              <path d=\"m13.5 6.5 4 4\" />\n              <path d=\"M16.6 3.4a2 2 0 0 1 2.8 0l1.2 1.2a2 2 0 0 1 0 2.8L18 10l-4-4 2.6-2.6Z\" />\n              <path d=\"M14.5 7.5 5 17v2h2l9.5-9.5\" />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {showInput && (\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"flex h-9 items-stretch overflow-hidden rounded-md border border-zinc-900/15 bg-white focus-within:border-[var(--cp-accent)] dark:border-white/15 dark:bg-white/[0.04]\">\n            <select\n              value={fmt}\n              onChange={(event) => {\n                setFmt(event.target.value as ColorFormat);\n                setDraft(null);\n              }}\n              aria-label={`${label}: text format`}\n              className=\"cursor-pointer border-r border-zinc-900/10 bg-transparent px-2 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 outline-none focus-visible:bg-zinc-900/5 dark:border-white/10 dark:text-zinc-400 dark:focus-visible:bg-white/5 [&>option]:bg-white [&>option]:text-zinc-900 dark:[&>option]:bg-zinc-900 dark:[&>option]:text-zinc-100\"\n            >\n              <option value=\"hex\">Hex</option>\n              <option value=\"rgb\">RGB</option>\n              <option value=\"hsl\">HSL</option>\n            </select>\n            <input\n              type=\"text\"\n              value={draft ?? formatted}\n              spellCheck={false}\n              autoComplete=\"off\"\n              aria-label={`${label}: ${fmt === \"hex\" ? \"hex\" : fmt.toUpperCase()} value`}\n              aria-invalid={!draftValid || undefined}\n              aria-describedby={errorId}\n              onChange={(event) => {\n                setDraft(event.target.value);\n                applyText(event.target.value);\n              }}\n              onKeyDown={(event) => {\n                if (event.key === \"Enter\") {\n                  event.preventDefault();\n                  if (draft !== null && applyText(draft)) {\n                    setDraft(null);\n                    commit();\n                  }\n                } else if (event.key === \"Escape\" && draft !== null) {\n                  event.preventDefault();\n                  setDraft(null);\n                }\n              }}\n              onBlur={() => {\n                if (draft !== null && draftValid) commit();\n                setDraft(null);\n              }}\n              className=\"min-w-0 flex-1 bg-transparent px-2.5 font-mono text-xs tabular-nums text-zinc-900 outline-none aria-[invalid]:text-red-600 dark:text-zinc-100 dark:aria-[invalid]:text-red-400\"\n            />\n          </div>\n          <p id={errorId} aria-live=\"polite\" className=\"min-h-4 font-mono text-[10px] tracking-[0.04em] text-red-600 dark:text-red-400\">\n            {draftValid ? \"\" : fmt === \"hex\" ? \"Use a hex colour like #FF7448\" : fmt === \"rgb\" ? \"Use rgb(255, 116, 72)\" : \"Use hsl(15, 100%, 64%)\"}\n          </p>\n        </div>\n      )}\n\n      {swatches.length > 0 && (\n        <div role=\"group\" aria-label={`${label}: presets`} className=\"flex flex-wrap gap-1.5\">\n          {swatches.map((swatch) => {\n            const parsed = parseColor(swatch);\n            if (!parsed) return null;\n            const swatchHex = toHex(alpha ? parsed : { ...parsed, a: 1 });\n            const active = swatchHex === hex;\n            return (\n              <button\n                key={swatch}\n                type=\"button\"\n                aria-label={swatchHex}\n                aria-pressed={active}\n                onClick={() => {\n                  update(alpha ? parsed : { ...parsed, a: 1 });\n                  commit();\n                  setDraft(null);\n                }}\n                className={`relative size-6 overflow-hidden rounded-md outline-none transition-transform duration-150 hover:scale-110 focus-visible:ring-2 focus-visible:ring-[var(--cp-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:focus-visible:ring-offset-[#131619] ${checker} ${\n                  active ? \"ring-2 ring-[var(--cp-accent)] ring-offset-2 ring-offset-white dark:ring-offset-[#131619]\" : \"\"\n                }`}\n              >\n                <span aria-hidden=\"true\" className=\"absolute inset-0 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.14)]\" style={{ background: swatchHex }} />\n              </button>\n            );\n          })}\n        </div>\n      )}\n\n      {name !== undefined && <input type=\"hidden\" name={name} value={hex} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/color-picker"
}
