{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "type": "registry:ui",
  "title": "Color picker",
  "description": "A swatch that grows into a picker with format morphing, eyedropper, saved swatches, and contrast readout.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/text-morph.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/color-picker/color-picker.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\nimport {\n  AnimatePresence,\n  Reorder,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { Check, Pipette, Plus } from \"lucide-react\";\nimport { TextMorph } from \"../text-morph/text-morph\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\nexport type ColorFormat = \"hex\" | \"rgb\" | \"hsl\" | \"oklch\";\nexport interface ColorSwatch {\n  id: string;\n  color: string;\n}\n\n/**\n * A color field whose swatch grows into a full picker. Pick saturation and brightness on the area, hue and opacity on the sliders, or type a value\n * in hex, RGB, HSL, or OKLCH; the format button morphs the text between them. The eyedropper appears where the browser supports it, saved\n * swatches can be added, applied, dragged or moved with Alt and the arrow keys, and removed with Delete. A contrast readout compares the color with\n * the background it will sit on. Arrow keys move every thumb, Shift takes bigger steps, and Escape closes the panel.\n */\nexport interface ColorPickerProps {\n  /** Any color the picker can read: hex, rgb(), hsl(), or oklch(). */\n  value?: string;\n  defaultValue?: string;\n  /** Receives the color as hex, with two alpha digits when it is not opaque. */\n  onValueChange?: (hex: string) => void;\n  /** The background the color will sit on, for the contrast readout. */\n  background?: string;\n  /** Name shown on the swatch, such as \"Accent\". */\n  label?: string;\n  swatches?: ColorSwatch[];\n  defaultSwatches?: ColorSwatch[];\n  onSwatchesChange?: (swatches: ColorSwatch[]) => void;\n  /** Most saved swatches. Saving past it drops the oldest. */\n  maxSwatches?: number;\n  defaultFormat?: ColorFormat;\n  className?: string;\n}\n\nexport type Hsva = { h: number; s: number; v: number; a: number };\ntype Rgba = { r: number; g: number; b: number; a: number };\n\nconst { spring, duration, ease, blur } = motionTokens;\nconst clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value));\nconst round = (value: number, digits = 0) => {\n  const f = 10 ** digits;\n  return Math.round(value * f) / f;\n};\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\",\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    mass: 1,\n    restDelta: 0.0005,\n    restSpeed: 0.005,\n  };\n};\n/** Thumbs chase the pointer on a quick spring with a little life, so a click lands with a soft settle and a drag trails by a hair. */\nconst thumbSpring = physical(0.26, 0);\n/** Under a finger or mouse drag the thumb stays glued to the pointer: a stiff spring with no bounce, so it never trails or wobbles. */\nconst dragSpring = physical(0.1, 0);\nconst openSpring = physical(spring.morph.visualDuration, 0.1),\n  closeSpring = physical(0.3, 0);\nconst instant: Transition = { duration: 0 };\n\n/* Color math. sRGB channels are 0 to 1. */\nfunction hsvToRgb({ h, s, v, a }: Hsva): Rgba {\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));\n  };\n  return { r: f(5), g: f(3), b: f(1), a };\n}\nfunction rgbToHsv({ r, g, b, a }: Rgba, hue = 0): Hsva {\n  const max = Math.max(r, g, b),\n    min = Math.min(r, g, b),\n    d = max - min;\n  let h = hue;\n  if (d > 1e-6) {\n    if (max === r) h = 60 * (((g - b) / d) % 6);\n    else if (max === g) h = 60 * ((b - r) / d + 2);\n    else h = 60 * ((r - g) / d + 4);\n    if (h < 0) h += 360;\n  }\n  return { h, s: max === 0 ? 0 : d / max, v: max, a };\n}\nfunction hslToRgb(h: number, s: number, l: number, a: number): Rgba {\n  const k = (n: number) => (n + h / 30) % 12,\n    c = s * Math.min(l, 1 - l);\n  const f = (n: number) => l - c * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));\n  return { r: f(0), g: f(8), b: f(4), a };\n}\nfunction rgbToHsl({ r, g, b }: Rgba, hue: number) {\n  const max = Math.max(r, g, b),\n    min = Math.min(r, g, b),\n    l = (max + min) / 2,\n    d = max - min;\n  const s = d < 1e-6 ? 0 : d / (1 - Math.abs(2 * l - 1));\n  return { h: d < 1e-6 ? hue : rgbToHsv({ r, g, b, a: 1 }).h, s, l };\n}\nconst toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);\nconst fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);\nfunction rgbToOklch({ r, g, b }: Rgba, hue: number) {\n  const lr = toLinear(r),\n    lg = toLinear(g),\n    lb = toLinear(b);\n  const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);\n  const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);\n  const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);\n  const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;\n  const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;\n  const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;\n  const C = Math.hypot(A, B);\n  let H = (Math.atan2(B, A) * 180) / Math.PI;\n  if (H < 0) H += 360;\n  return { l: L, c: C, h: C < 0.0005 ? hue : H };\n}\nfunction oklchToRgb(L: number, C: number, H: number, a: number): Rgba {\n  const A = C * Math.cos((H * Math.PI) / 180),\n    B = C * Math.sin((H * Math.PI) / 180);\n  const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;\n  const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;\n  const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;\n  return {\n    r: clamp(fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)),\n    g: clamp(fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)),\n    b: clamp(fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)),\n    a,\n  };\n}\nconst byte = (c: number) => Math.round(clamp(c) * 255);\nconst hex2 = (c: number) => byte(c).toString(16).padStart(2, \"0\").toUpperCase();\n\nexport function toHex(hsva: Hsva) {\n  const { r, g, b, a } = hsvToRgb(hsva);\n  return `#${hex2(r)}${hex2(g)}${hex2(b)}${a < 0.999 ? hex2(a) : \"\"}`;\n}\nfunction format(hsva: Hsva, kind: ColorFormat) {\n  const rgb = hsvToRgb(hsva);\n  const alpha = hsva.a < 0.999 ? ` / ${Math.round(hsva.a * 100)}%` : \"\";\n  if (kind === \"hex\") return toHex(hsva);\n  if (kind === \"rgb\") return `rgb(${byte(rgb.r)} ${byte(rgb.g)} ${byte(rgb.b)}${alpha})`;\n  if (kind === \"hsl\") {\n    const { h, s, l } = rgbToHsl(rgb, hsva.h);\n    return `hsl(${Math.round(h) % 360} ${Math.round(s * 100)}% ${Math.round(l * 100)}%${alpha})`;\n  }\n  const { l, c, h } = rgbToOklch(rgb, hsva.h);\n  return `oklch(${round(l * 100, 1)}% ${round(c, 3)} ${round(h, 1) % 360}${alpha})`;\n}\nconst readAlpha = (text?: string) =>\n  text === undefined ? 1 : text.endsWith(\"%\") ? clamp(parseFloat(text) / 100) : clamp(parseFloat(text));\n\n/** Reads hex, rgb(), hsl(), and oklch() in modern or comma syntax. Returns null for anything else. */\nexport function parseColor(input: string, hue = 0): Hsva | null {\n  const text = input.trim().toLowerCase();\n  let match = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text);\n  if (match) {\n    let digits = match[1]!;\n    if (digits.length <= 4)\n      digits = digits\n        .split(\"\")\n        .map((d) => d + d)\n        .join(\"\");\n    const n = (i: number) => parseInt(digits.slice(i, i + 2), 16) / 255;\n    return rgbToHsv({ r: n(0), g: n(2), b: n(4), a: digits.length === 8 ? n(6) : 1 }, hue);\n  }\n  match = /^rgba?\\(\\s*([\\d.]+)[\\s,]+([\\d.]+)[\\s,]+([\\d.]+)(?:\\s*[/,]\\s*([\\d.]+%?))?\\s*\\)$/.exec(text);\n  if (match)\n    return rgbToHsv(\n      { r: clamp(+match[1]! / 255), g: clamp(+match[2]! / 255), b: clamp(+match[3]! / 255), a: readAlpha(match[4]) },\n      hue,\n    );\n  match = /^hsla?\\(\\s*([\\d.]+)(?:deg)?[\\s,]+([\\d.]+)%?[\\s,]+([\\d.]+)%?(?:\\s*[/,]\\s*([\\d.]+%?))?\\s*\\)$/.exec(text);\n  if (match) {\n    const h = +match[1]! % 360;\n    const next = rgbToHsv(hslToRgb(h, clamp(+match[2]! / 100), clamp(+match[3]! / 100), readAlpha(match[4])), h);\n    return { ...next, h };\n  }\n  match = /^oklch\\(\\s*([\\d.]+)(%?)\\s+([\\d.]+)\\s+([\\d.]+)(?:deg)?(?:\\s*\\/\\s*([\\d.]+%?))?\\s*\\)$/.exec(text);\n  if (match) {\n    const L = match[2] ? +match[1]! / 100 : +match[1]!;\n    return rgbToHsv(oklchToRgb(clamp(L), +match[3]!, +match[4]!, readAlpha(match[5])), hue);\n  }\n  return null;\n}\n\n/** WCAG contrast of a color, composited over the background, against that background. */\nfunction contrast(hsva: Hsva, background: Rgba) {\n  const fg = hsvToRgb(hsva);\n  const mix = (c: number, bg: number) => c * fg.a + bg * (1 - fg.a);\n  const lum = (r: number, g: number, b: number) => 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n  const a = lum(mix(fg.r, background.r), mix(fg.g, background.g), mix(fg.b, background.b)),\n    b = lum(background.r, background.g, background.b);\n  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);\n}\nconst level = (ratio: number) => (ratio >= 7 ? \"AAA\" : ratio >= 4.5 ? \"AA\" : ratio >= 3 ? \"AA large\" : \"Fails\");\n/** WCAG thresholds are never rounded up (4.499 fails AA), so the readout truncates to match the grade beside it. */\nconst ratioLabel = (ratio: number) => (Math.floor(ratio * 100) / 100).toFixed(2);\n\nconst formats: ColorFormat[] = [\"hex\", \"rgb\", \"hsl\", \"oklch\"];\nconst formatNames: Record<ColorFormat, string> = { hex: \"Hex\", rgb: \"RGB\", hsl: \"HSL\", oklch: \"OKLCH\" };\n\nconst subscribe = () => () => {};\nfunction useHydrated() {\n  return useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n}\nfunction useEyeDropper() {\n  return useSyncExternalStore(\n    subscribe,\n    () => \"EyeDropper\" in window,\n    () => false,\n  );\n}\ntype EyeDropperCtor = new () => { open: () => Promise<{ sRGBHex: string }> };\n\n/** Springs a motion value to a new target, or jumps under reduced motion. Retargeting keeps the current velocity. */\nfunction useFollow(target: number, reduced: boolean, dragging = false) {\n  const value = useMotionValue(target);\n  useEffect(() => {\n    if (reduced) {\n      value.jump(target);\n      return;\n    }\n    const controls = animate(value, target, dragging ? dragSpring : thumbSpring);\n    return () => controls.stop();\n  }, [dragging, reduced, target, value]);\n  return value;\n}\n\n/** Tracks a pointer drag over an element and reports its position as fractions of the element's box. */\nfunction usePad(onMove: (x: number, y: number) => void, onActive: (active: boolean) => void) {\n  const pointer = useRef<number | null>(null);\n  const read = (event: ReactPointerEvent<HTMLElement>) => {\n    const box = event.currentTarget.getBoundingClientRect();\n    onMove(clamp((event.clientX - box.left) / box.width), clamp((event.clientY - box.top) / box.height));\n  };\n  return {\n    onPointerDown(event: ReactPointerEvent<HTMLElement>) {\n      if (event.button !== 0 || pointer.current !== null) return;\n      pointer.current = event.pointerId;\n      event.currentTarget.setPointerCapture(event.pointerId);\n      onActive(true);\n      read(event);\n    },\n    onPointerMove(event: ReactPointerEvent<HTMLElement>) {\n      if (event.pointerId === pointer.current) read(event);\n    },\n    onPointerUp(event: ReactPointerEvent<HTMLElement>) {\n      if (event.pointerId !== pointer.current) return;\n      pointer.current = null;\n      onActive(false);\n    },\n    onPointerCancel(event: ReactPointerEvent<HTMLElement>) {\n      if (event.pointerId !== pointer.current) return;\n      pointer.current = null;\n      onActive(false);\n    },\n  };\n}\n\n/** Arrow keys step by a small amount, Shift by ten, Page keys by ten, Home and End to the ends. */\nfunction stepFor(event: ReactKeyboardEvent, axis: \"x\" | \"y\" | \"both\") {\n  const big = event.shiftKey || event.key.startsWith(\"Page\");\n  const size = big ? 10 : 1;\n  const map: Record<string, [number, number]> = {\n    ArrowRight: [size, 0],\n    ArrowLeft: [-size, 0],\n    ArrowUp: axis === \"both\" ? [0, size] : [size, 0],\n    ArrowDown: axis === \"both\" ? [0, -size] : [-size, 0],\n    PageUp: [10, 0],\n    PageDown: [-10, 0],\n  };\n  return map[event.key] ?? null;\n}\n\n/** The trigger and the panel head share one row: the panel opens over the trigger and grows out of its box. */\nconst ROW = \"flex h-11 items-center gap-[10px] py-1.5 pl-1.5\";\nconst LABEL_TEXT = \"grid min-w-0 text-start leading-[1.15]\";\nconst THUMB =\n  \"absolute size-[22px] -translate-1/2 rounded-full shadow-[0_0_0_3px_#fff,0_2px_8px_oklch(0%_0_0/.3),inset_0_0_0_1px_oklch(0%_0_0/.12)] [transition:scale_var(--duration-spring)_var(--ease-spring)] motion-reduce:[transition:none]\";\nconst ICON_BUTTON =\n  \"grid size-8 cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-fast)_var(--ease-standard)] hover:bg-hover hover:text-ink active:[transform:scale(.94)] motion-reduce:[transition:none]\";\n/* The gradient stops sit at the thumb's travel ends, so the color under the thumb is the value it reports. */\nconst HUE_TRACK =\n  \"linear-gradient(to right, #f00 11px, #ff0 calc(11px + (100% - 22px) * .1666), #0f0 calc(11px + (100% - 22px) * .3333), #0ff 50%, #00f calc(11px + (100% - 22px) * .6666), #f0f calc(11px + (100% - 22px) * .8333), #f00 calc(100% - 11px))\";\nconst ALPHA_TRACK =\n  \"linear-gradient(to right, transparent 11px, var(--picker-opaque) calc(100% - 11px)), var(--checker)\";\nconst GRADE = {\n  pass: \"bg-[color-mix(in_oklab,var(--green)_14%,transparent)] text-green\",\n  large: \"bg-[color-mix(in_oklab,var(--orange)_16%,transparent)] text-orange\",\n  fail: \"bg-[color-mix(in_oklab,var(--red)_12%,transparent)] text-red\",\n} as const;\n\ninterface SliderProps {\n  label: string;\n  value: number;\n  valueText: string;\n  max: number;\n  unit: number;\n  onChange: (value: number) => void;\n  style?: CSSProperties;\n  reduced: boolean;\n  fill: string;\n  onActive: (active: boolean) => void;\n  active: boolean;\n}\nfunction Slider({ label, value, valueText, max, unit, onChange, style, reduced, fill, onActive, active }: SliderProps) {\n  const x = useFollow(value / max, reduced, active);\n  const left = useTransform(x, (v) => `${v * 100}%`);\n  const pad = usePad((fx) => onChange(fx * max), onActive);\n  return (\n    <div\n      className={cn(\n        \"relative mx-[11px] h-[14px] cursor-pointer touch-none rounded-full before:absolute before:-inset-x-[11px] before:inset-y-0 before:rounded-[inherit] before:[background:var(--track)] before:shadow-[inset_0_0_0_1px_oklch(0%_0_0/.08)] before:content-['']\",\n      )}\n      style={style}\n      {...pad}\n    >\n      <motion.div\n        className={cn(\n          THUMB,\n          \"top-1/2 cursor-grab [background:var(--checker)] after:absolute after:inset-0 after:rounded-[inherit] after:[background:var(--thumb-fill)] after:content-['']\",\n          active && \"scale-[1.2] cursor-grabbing\",\n        )}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={max}\n        aria-valuenow={Math.round(value)}\n        aria-valuetext={valueText}\n        style={{ left, \"--thumb-fill\": fill } as unknown as CSSProperties}\n        data-active={active || undefined}\n        onKeyDown={(event) => {\n          if (event.key === \"Home\" || event.key === \"End\") {\n            event.preventDefault();\n            onChange(event.key === \"Home\" ? 0 : max);\n            return;\n          }\n          const step = stepFor(event, \"x\");\n          if (!step) return;\n          event.preventDefault();\n          onChange(clamp(value + step[0] * unit, 0, max));\n        }}\n      />\n    </div>\n  );\n}\n\nfunction Chip({ color, className }: { color: string; className?: string }) {\n  // A chip over a checkerboard, so opacity reads as opacity.\n  return (\n    <span\n      className={cn(\n        \"relative block size-8 flex-none overflow-hidden rounded-full [background:var(--checker)]\",\n        className,\n      )}\n    >\n      <span\n        className=\"absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_oklch(0%_0_0/.1)]\"\n        style={{ background: color }}\n      />\n    </span>\n  );\n}\n\nlet swatchCount = 0;\nconst newId = () => `swatch-${Date.now().toString(36)}-${swatchCount++}`;\n\nexport function ColorPicker({\n  value,\n  defaultValue = \"#2F6BFF\",\n  onValueChange,\n  background = \"#FFFFFF\",\n  label = \"Color\",\n  swatches: swatchesProp,\n  defaultSwatches,\n  onSwatchesChange,\n  maxSwatches = 7,\n  defaultFormat = \"hex\",\n  className,\n}: ColorPickerProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n  const hydrated = useHydrated();\n  const reduced = !!useReducedMotion() && hydrated;\n  const canPick = useEyeDropper();\n\n  const [hsva, setHsva] = useState<Hsva>(() => parseColor(value ?? defaultValue) ?? { h: 220, s: 0.8, v: 1, a: 1 });\n  const [synced, setSynced] = useState(value);\n  if (value !== undefined && value !== synced) {\n    setSynced(value);\n    if (value.toUpperCase() !== toHex(hsva)) {\n      const next = parseColor(value, hsva.h);\n      if (next) setHsva(next);\n    }\n  }\n  const [ownSwatches, setOwnSwatches] = useState<ColorSwatch[]>(defaultSwatches ?? []);\n  const swatches = swatchesProp ?? ownSwatches;\n  const [open, setOpen] = useState(false);\n  const [shown, setShown] = useState(false);\n  const [kind, setKind] = useState<ColorFormat>(defaultFormat);\n  const [draft, setDraft] = useState<string | null>(null);\n  const [invalid, setInvalid] = useState(false);\n  const [morph, setMorph] = useState<{ id: number; from: string; to: string; phase: 0 | 1 } | null>(null);\n  const [active, setActive] = useState<\"area\" | \"hue\" | \"alpha\" | null>(null);\n  const [focusSwatch, setFocusSwatch] = useState<string | null>(null);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const areaThumb = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const swatchRefs = useRef(new Map<string, HTMLButtonElement>());\n  const dragged = useRef(false);\n  const fieldX = useMotionValue(0);\n  /** Whether the panel is meant to be open right now, read when a closing animation finishes. */\n  const openNow = useRef(false);\n\n  const hex = toHex(hsva);\n  const rgb = hsvToRgb(hsva);\n  const css = `rgb(${byte(rgb.r)} ${byte(rgb.g)} ${byte(rgb.b)} / ${round(hsva.a, 3)})`;\n  const opaque = `rgb(${byte(rgb.r)} ${byte(rgb.g)} ${byte(rgb.b)})`;\n  const pure = `hsl(${hsva.h} 100% 50%)`;\n  const bg = parseColor(background);\n  const ratio = contrast(hsva, bg ? hsvToRgb(bg) : { r: 1, g: 1, b: 1, a: 1 });\n  const text = format(hsva, kind);\n\n  function commit(next: Hsva) {\n    setHsva(next);\n    const nextHex = toHex(next);\n    if (nextHex !== hex) onValueChange?.(nextHex);\n  }\n  function setSwatches(next: ColorSwatch[]) {\n    if (!swatchesProp) setOwnSwatches(next);\n    onSwatchesChange?.(next);\n  }\n\n  // The surface: one progress value grows the swatch's box into the panel's box; the content fades in behind the leading edge.\n  const progress = useMotionValue(0),\n    fade = useMotionValue(0);\n  const size = { W: useMotionValue(300), H: useMotionValue(420), w: useMotionValue(160), h: useMotionValue(44) };\n  const clipPath = useTransform(() => {\n    const q = clamp(progress.get());\n    return `inset(0px ${round((size.W.get() - size.w.get()) * (1 - q), 2)}px ${round((size.H.get() - size.h.get()) * (1 - q), 2)}px 0px round ${round(22 + 4 * q, 2)}px)`;\n  });\n  const contentOpacity = useTransform(progress, [0.35, 0.9], [0, 1]);\n  const contentY = useTransform(progress, [0, 1], [-10, 0]);\n\n  useLayoutEffect(() => {\n    if (!shown) return;\n    const panel = panelRef.current,\n      trigger = triggerRef.current;\n    if (!panel || !trigger) return;\n    const measure = () => {\n      size.W.set(panel.offsetWidth);\n      size.H.set(panel.offsetHeight);\n      size.w.set(trigger.offsetWidth);\n      size.h.set(trigger.offsetHeight);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(panel);\n    return () => observer.disconnect();\n  }, [shown, size.W, size.H, size.w, size.h]);\n\n  useEffect(() => {\n    if (!shown) return;\n    if (reduced) {\n      progress.jump(open ? 1 : progress.get());\n      const controls = animate(fade, open ? 1 : 0, { duration: duration.fast, ease: [...ease.standard] });\n      void controls.then(() => {\n        if (!openNow.current) {\n          progress.jump(0);\n          setShown(false);\n        }\n      });\n      return () => controls.stop();\n    }\n    fade.jump(1);\n    const controls = animate(progress, open ? 1 : 0, open ? openSpring : closeSpring);\n    if (!open)\n      void controls.then(() => {\n        if (!openNow.current) setShown(false);\n      });\n    return () => controls.stop();\n  }, [fade, open, progress, reduced, shown]);\n\n  useEffect(() => {\n    if (open) requestAnimationFrame(() => areaThumb.current?.focus({ preventScroll: true }));\n  }, [open]);\n\n  function show() {\n    openNow.current = true;\n    setShown(true);\n    setOpen(true);\n  }\n  function hide(returnFocus = true) {\n    openNow.current = false;\n    setOpen(false);\n    setDraft(null);\n    setInvalid(false);\n    if (returnFocus) triggerRef.current?.focus({ preventScroll: true });\n  }\n\n  const dismiss = useEffectEvent((event: Event) => {\n    if (event instanceof KeyboardEvent) {\n      if (event.key !== \"Escape\") return;\n      hide(!!rootRef.current?.contains(document.activeElement));\n      return;\n    }\n    if (!rootRef.current?.contains(event.target as Node)) hide(false);\n  });\n  useEffect(() => {\n    if (!open) return;\n    const listener = (event: Event) => dismiss(event);\n    document.addEventListener(\"pointerdown\", listener, true);\n    document.addEventListener(\"keydown\", listener);\n    return () => {\n      document.removeEventListener(\"pointerdown\", listener, true);\n      document.removeEventListener(\"keydown\", listener);\n    };\n  }, [open]);\n\n  // The saturation and brightness area.\n  const areaX = useFollow(hsva.s, reduced, active === \"area\"),\n    areaY = useFollow(1 - hsva.v, reduced, active === \"area\");\n  const areaLeft = useTransform(areaX, (v) => `${v * 100}%`),\n    areaTop = useTransform(areaY, (v) => `${v * 100}%`);\n  const areaPad = usePad(\n    (x, y) => commit({ ...hsva, s: x, v: 1 - y }),\n    (on) => setActive(on ? \"area\" : null),\n  );\n\n  // Format morph: the new text mounts showing the old string, then morphs to the new one, and the live field returns once it settles.\n  useEffect(() => {\n    if (!morph || morph.phase === 1) return;\n    const frame = requestAnimationFrame(() =>\n      setMorph((current) => (current && current.id === morph.id ? { ...current, phase: 1 } : current)),\n    );\n    return () => cancelAnimationFrame(frame);\n  }, [morph]);\n  useEffect(() => {\n    if (!morph || morph.phase === 0) return;\n    const timer = window.setTimeout(() => setMorph((current) => (current?.id === morph.id ? null : current)), 620);\n    return () => window.clearTimeout(timer);\n  }, [morph]);\n\n  function cycleFormat() {\n    const next = formats[(formats.indexOf(kind) + 1) % formats.length]!;\n    setKind(next);\n    setDraft(null);\n    setInvalid(false);\n    if (!reduced) setMorph({ id: Date.now(), from: text, to: format(hsva, next), phase: 0 });\n  }\n\n  function submitDraft() {\n    if (draft === null) return true;\n    const next = parseColor(draft, hsva.h);\n    if (!next) {\n      setInvalid(true);\n      if (!reduced) animate(fieldX, [0, -5, 4, -2, 0], { duration: 0.32, ease: [...ease.standard] });\n      return false;\n    }\n    commit(next);\n    setDraft(null);\n    setInvalid(false);\n    return true;\n  }\n\n  function pickFromScreen() {\n    if (!canPick) return;\n    const Ctor = (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper;\n    new Ctor()\n      .open()\n      .then((result) => {\n        const next = parseColor(result.sRGBHex, hsva.h);\n        if (next) commit({ ...next, a: hsva.a });\n      })\n      .catch(() => {});\n  }\n\n  function saveSwatch() {\n    const entry = { id: newId(), color: hex };\n    setSwatches([entry, ...swatches].slice(0, maxSwatches));\n    setFocusSwatch(entry.id);\n  }\n  function onSwatchKey(event: ReactKeyboardEvent<HTMLButtonElement>, index: number) {\n    const item = swatches[index]!;\n    const move = event.key === \"ArrowRight\" ? 1 : event.key === \"ArrowLeft\" ? -1 : 0;\n    if (event.key === \"Delete\" || event.key === \"Backspace\") {\n      event.preventDefault();\n      const next = swatches.filter((entry) => entry.id !== item.id);\n      setSwatches(next);\n      const neighbor = next[Math.min(index, next.length - 1)];\n      setFocusSwatch(neighbor?.id ?? null);\n      requestAnimationFrame(() =>\n        neighbor\n          ? swatchRefs.current.get(neighbor.id)?.focus({ preventScroll: true })\n          : inputRef.current?.focus({ preventScroll: true }),\n      );\n      return;\n    }\n    if (!move && event.key !== \"Home\" && event.key !== \"End\") return;\n    event.preventDefault();\n    if (move && event.altKey) {\n      const to = index + move;\n      if (to < 0 || to >= swatches.length) return;\n      const next = [...swatches];\n      next.splice(index, 1);\n      next.splice(to, 0, item);\n      setSwatches(next);\n      requestAnimationFrame(() => swatchRefs.current.get(item.id)?.focus({ preventScroll: true }));\n      return;\n    }\n    const target =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? swatches.length - 1\n          : (index + move + swatches.length) % swatches.length;\n    setFocusSwatch(swatches[target]!.id);\n    swatchRefs.current.get(swatches[target]!.id)?.focus({ preventScroll: true });\n  }\n\n  const ratioValue = useFollow(ratio, reduced);\n  const ratioText = useTransform(ratioValue, ratioLabel);\n  const grade = level(ratio);\n  const gradeKey = grade === \"Fails\" ? \"fail\" : grade === \"AA large\" ? \"large\" : \"pass\";\n  const hidden = reduced ? { opacity: 0 } : { opacity: 0, y: \"0.3em\", filter: `blur(${blur.subtle}px)` };\n  const swatchTab = swatches.some((entry) => entry.id === focusSwatch) ? focusSwatch : swatches[0]?.id;\n  const style = {\n    \"--picker-color\": css,\n    \"--picker-opaque\": opaque,\n    \"--picker-hue\": pure,\n    \"--picker-bg\": background,\n  } as CSSProperties;\n\n  // The swatch is the panel's top left corner: the panel opens over it and grows out of its box. Leave room below and to the right.\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative inline-block font-body tracking-body text-ink [--checker:repeating-conic-gradient(oklch(88%_0_0)_0_25%,oklch(100%_0_0)_0_50%)_0_0/10px_10px] [--picker-fill:var(--surface)] [--picker-width:min(19rem,calc(100vw_-_32px))] dark:[--checker:repeating-conic-gradient(oklch(42%_0_0)_0_25%,oklch(30%_0_0)_0_50%)_0_0/10px_10px]\",\n        className,\n      )}\n      style={style}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        className={cn(\n          ROW,\n          \"cursor-pointer rounded-button border border-line bg-(--picker-fill) pr-4 text-inherit shadow-resting [-webkit-tap-highlight-color:transparent] transition-[border-color] duration-fast ease-standard hover:border-line-strong contrast-more:border-line-strong\",\n        )}\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-controls={open ? `${uid}-panel` : undefined}\n        onClick={() => (open ? hide() : show())}\n      >\n        <Chip color={css} />\n        <span className={LABEL_TEXT}>\n          <span className=\"text-xs leading-[1.15] text-ink-2\">{label}</span>\n          <span className=\"text-sm leading-[1.15] font-medium tabular-nums\">{hex}</span>\n        </span>\n      </button>\n\n      {shown && (\n        <div\n          className={cn(\n            // The floating layer. Its drop shadow follows the clipped shape, so the shadow grows with the panel.\n            \"absolute top-[-1px] left-[-1px] z-20 [filter:drop-shadow(0_18px_36px_oklch(0%_0_0/.12))_drop-shadow(0_2px_6px_oklch(0%_0_0/.06))] dark:[filter:drop-shadow(0_20px_44px_oklch(0%_0_0/.5))]\",\n            open ? \"pointer-events-auto\" : \"pointer-events-none\",\n          )}\n          data-open={open || undefined}\n        >\n          <motion.div\n            ref={panelRef}\n            id={`${uid}-panel`}\n            role=\"dialog\"\n            aria-label={`${label} color`}\n            className=\"w-(--picker-width) rounded-panel border border-line bg-(--picker-fill) pb-[14px] will-change-[clip-path] contrast-more:border-line-strong\"\n            inert={!open}\n            style={{ clipPath, opacity: fade }}\n          >\n            <div className={cn(ROW, \"pr-1.5\")}>\n              <Chip color={css} />\n              <span className={cn(LABEL_TEXT, \"flex-1\")}>\n                <span className=\"text-xs leading-[1.15] text-ink-2\">{label}</span>\n                <span className=\"text-sm leading-[1.15] font-medium tabular-nums\">{hex}</span>\n              </span>\n              <motion.span className=\"flex gap-0.5\" style={{ opacity: contentOpacity }}>\n                {canPick && (\n                  <button\n                    type=\"button\"\n                    className={ICON_BUTTON}\n                    aria-label=\"Pick a color from the screen\"\n                    onClick={pickFromScreen}\n                  >\n                    <Pipette size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n                  </button>\n                )}\n                <button type=\"button\" className={ICON_BUTTON} aria-label=\"Done\" onClick={() => hide()}>\n                  <Check size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n                </button>\n              </motion.span>\n            </div>\n\n            {/* One column locked to the panel: a wider grade label (\"AA large\") must never widen the area and sliders mid drag. */}\n            <motion.div\n              className=\"grid grid-cols-[minmax(0,1fr)] gap-[14px] px-[14px] pt-2\"\n              style={{ opacity: contentOpacity, y: contentY }}\n            >\n              {/* Saturation runs left to right, brightness bottom to top, over the pure hue. */}\n              <div\n                className=\"relative h-[164px] cursor-crosshair touch-none rounded-[16px] [background:linear-gradient(to_top,#000,transparent),linear-gradient(to_right,#fff,transparent),var(--picker-hue)] shadow-[inset_0_0_0_1px_oklch(0%_0_0/.08)]\"\n                data-active={active === \"area\" || undefined}\n                {...areaPad}\n              >\n                <motion.div\n                  ref={areaThumb}\n                  className={cn(\n                    THUMB,\n                    \"cursor-grab bg-(--picker-opaque)\",\n                    active === \"area\" && \"scale-[1.2] cursor-grabbing\",\n                  )}\n                  role=\"slider\"\n                  tabIndex={0}\n                  aria-roledescription=\"2D slider\"\n                  aria-label=\"Saturation and brightness\"\n                  aria-valuemin={0}\n                  aria-valuemax={100}\n                  aria-valuenow={Math.round(hsva.s * 100)}\n                  aria-valuetext={`Saturation ${Math.round(hsva.s * 100)}%, brightness ${Math.round(hsva.v * 100)}%`}\n                  style={{ left: areaLeft, top: areaTop }}\n                  onKeyDown={(event) => {\n                    const step = stepFor(event, \"both\");\n                    if (!step) return;\n                    event.preventDefault();\n                    commit({ ...hsva, s: clamp(hsva.s + step[0] / 100), v: clamp(hsva.v + step[1] / 100) });\n                  }}\n                />\n              </div>\n\n              <Slider\n                label=\"Hue\"\n                value={hsva.h}\n                valueText={`${Math.round(hsva.h)} degrees`}\n                max={360}\n                unit={1}\n                style={{ \"--track\": HUE_TRACK } as CSSProperties}\n                reduced={reduced}\n                fill={pure}\n                onChange={(h) => commit({ ...hsva, h: clamp(h, 0, 359.9) })}\n                active={active === \"hue\"}\n                onActive={(on) => setActive(on ? \"hue\" : null)}\n              />\n              <Slider\n                label=\"Opacity\"\n                value={hsva.a * 100}\n                valueText={`${Math.round(hsva.a * 100)}%`}\n                max={100}\n                unit={1}\n                style={{ \"--track\": ALPHA_TRACK } as CSSProperties}\n                reduced={reduced}\n                fill={css}\n                onChange={(a) => commit({ ...hsva, a: clamp(a / 100) })}\n                active={active === \"alpha\"}\n                onActive={(on) => setActive(on ? \"alpha\" : null)}\n              />\n\n              <motion.div\n                className={cn(\n                  \"mt-0.5 flex h-10 items-stretch overflow-hidden rounded-[14px] border bg-hover transition-[border-color] duration-fast ease-standard\",\n                  invalid ? \"border-red\" : \"border-line contrast-more:border-line-strong\",\n                )}\n                data-invalid={invalid || undefined}\n                style={{ x: fieldX }}\n              >\n                <button\n                  type=\"button\"\n                  className=\"flex min-w-14 cursor-pointer items-center justify-center border-0 border-r border-line bg-transparent px-[10px] text-xs leading-[inherit] font-medium text-ink transition-[background-color] duration-fast ease-standard hover:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)]\"\n                  onClick={cycleFormat}\n                  aria-label={`Format: ${formatNames[kind]}. Switch format`}\n                >\n                  <TextMorph>{formatNames[kind]}</TextMorph>\n                </button>\n                <span className=\"relative flex min-w-0 flex-1\">\n                  <input\n                    ref={inputRef}\n                    className={cn(\n                      \"w-full min-w-0 border-0 bg-transparent px-[10px] text-xs leading-[inherit] text-ink tabular-nums caret-ink\",\n                      morph && \"text-transparent\",\n                    )}\n                    value={draft ?? text}\n                    spellCheck={false}\n                    autoComplete=\"off\"\n                    aria-label={`${label} in ${formatNames[kind]}`}\n                    aria-invalid={invalid || undefined}\n                    aria-describedby={invalid ? `${uid}-error` : undefined}\n                    data-morphing={morph ? \"\" : undefined}\n                    onChange={(event) => {\n                      setDraft(event.target.value);\n                      setInvalid(false);\n                    }}\n                    onKeyDown={(event) => {\n                      if (event.key === \"Enter\") {\n                        event.preventDefault();\n                        if (submitDraft()) event.currentTarget.select();\n                      }\n                      if (event.key === \"Escape\" && draft !== null) {\n                        event.stopPropagation();\n                        event.nativeEvent.stopImmediatePropagation();\n                        setDraft(null);\n                        setInvalid(false);\n                      }\n                    }}\n                    onBlur={() => {\n                      if (!submitDraft()) {\n                        setDraft(null);\n                        setInvalid(false);\n                      }\n                    }}\n                  />\n                  {morph && (\n                    <span\n                      className=\"pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[10px] text-xs leading-[inherit] whitespace-nowrap text-ink tabular-nums\"\n                      aria-hidden=\"true\"\n                    >\n                      <TextMorph>{morph.phase === 0 ? morph.from : morph.to}</TextMorph>\n                    </span>\n                  )}\n                </span>\n              </motion.div>\n              <p\n                id={`${uid}-error`}\n                className=\"mx-0.5 mt-[-10px] mb-[-4px] min-h-0 text-xs leading-[inherit] text-red empty:hidden\"\n                aria-live=\"polite\"\n              >\n                {invalid ? \"Enter a hex, RGB, HSL, or OKLCH color\" : \"\"}\n              </p>\n\n              <div className=\"flex items-center gap-[10px] text-sm leading-[inherit]\">\n                <span\n                  className=\"grid h-7 w-9 flex-none place-items-center rounded-[9px] border border-line bg-(--picker-bg) font-medium text-(color:--picker-color)\"\n                  aria-hidden=\"true\"\n                >\n                  Aa\n                </span>\n                <span className=\"min-w-[4.4ch] font-medium tabular-nums\">\n                  <motion.span>{ratioText}</motion.span>:1\n                </span>\n                <span className=\"min-w-0 flex-1 truncate text-xs leading-[inherit] text-ink-2 max-[360px]:hidden\">\n                  against background\n                </span>\n                <span\n                  className={cn(\n                    \"relative inline-grid h-[22px] items-center rounded-full px-2 text-xs leading-[inherit] font-medium whitespace-nowrap transition-[background-color,color] duration-standard ease-standard\",\n                    GRADE[gradeKey],\n                  )}\n                  data-grade={gradeKey}\n                >\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <motion.span\n                      key={grade}\n                      initial={hidden}\n                      animate={{ opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n                      exit={{ ...hidden, transition: { duration: duration.instant } }}\n                      transition={\n                        reduced\n                          ? { duration: duration.instant }\n                          : { duration: duration.standard, ease: [...ease.enter] }\n                      }\n                    >\n                      {grade}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                <span className=\"sr-only\">{`Contrast ${ratioLabel(ratio)} to 1, ${grade === \"Fails\" ? \"fails\" : `passes ${grade}`}`}</span>\n              </div>\n\n              <div className=\"flex min-h-8 items-center gap-1.5\">\n                <button\n                  type=\"button\"\n                  className=\"grid size-[30px] flex-none cursor-pointer place-items-center rounded-button border border-dashed border-line-strong bg-transparent p-0 text-ink-2 [transition:color_var(--duration-fast)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-fast)_var(--ease-standard)] hover:border-ink hover:text-ink active:[transform:scale(.92)] motion-reduce:[transition:none]\"\n                  onClick={saveSwatch}\n                  aria-label={`Save ${hex}`}\n                >\n                  <Plus size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                </button>\n                <Reorder.Group\n                  as=\"div\"\n                  axis=\"x\"\n                  values={swatches}\n                  onReorder={setSwatches}\n                  className=\"flex min-w-0 flex-1 gap-1.5\"\n                  role=\"listbox\"\n                  aria-label=\"Saved colors\"\n                  aria-orientation=\"horizontal\"\n                >\n                  <AnimatePresence initial={false}>\n                    {swatches.map((entry, index) => (\n                      <Reorder.Item\n                        key={entry.id}\n                        value={entry}\n                        as=\"div\"\n                        className=\"relative flex-none touch-pan-y\"\n                        dragElastic={0.12}\n                        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                        animate={{ opacity: 1, scale: 1 }}\n                        exit={\n                          reduced ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: duration.fast } }\n                        }\n                        transition={reduced ? instant : { ...spring.snappy, layout: spring.smooth }}\n                        whileDrag={reduced ? undefined : { scale: 1.15, zIndex: 2 }}\n                        onDragStart={() => {\n                          dragged.current = true;\n                        }}\n                        onDragEnd={() =>\n                          requestAnimationFrame(() => {\n                            dragged.current = false;\n                          })\n                        }\n                      >\n                        <button\n                          ref={(node) => {\n                            if (node) swatchRefs.current.set(entry.id, node);\n                            else swatchRefs.current.delete(entry.id);\n                          }}\n                          type=\"button\"\n                          role=\"option\"\n                          aria-selected={entry.color.toUpperCase() === hex}\n                          className={cn(\n                            \"grid size-[30px] cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0\",\n                            entry.color.toUpperCase() === hex && \"shadow-[inset_0_0_0_2px_var(--ink)]\",\n                          )}\n                          data-current={entry.color.toUpperCase() === hex || undefined}\n                          aria-label={`${entry.color}. Alt and arrow keys to move, Delete to remove`}\n                          tabIndex={entry.id === swatchTab ? 0 : -1}\n                          onFocus={() => setFocusSwatch(entry.id)}\n                          onKeyDown={(event) => onSwatchKey(event, index)}\n                          onClick={() => {\n                            if (dragged.current) return;\n                            const next = parseColor(entry.color, hsva.h);\n                            if (next) commit(next);\n                          }}\n                        >\n                          <Chip\n                            color={entry.color}\n                            className={cn(\n                              \"size-[26px] [transition:scale_var(--duration-spring)_var(--ease-spring)] motion-reduce:[transition:none]\",\n                              entry.color.toUpperCase() === hex && \"scale-[.78]\",\n                            )}\n                          />\n                        </button>\n                      </Reorder.Item>\n                    ))}\n                  </AnimatePresence>\n                </Reorder.Group>\n              </div>\n            </motion.div>\n          </motion.div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default ColorPicker;\n",
      "type": "registry:component",
      "target": "@components/iris/color-picker/color-picker.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/color-picker",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "ColorPicker",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/color-picker"
  }
}
