{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json",
    "https://mcp.xzero.co.uk/r/text-morph.json"
  ],
  "files": [
    {
      "path": "registry/components/color-picker/color-picker.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 { AnimatePresence, Reorder, animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { Check, Pipette, Plus } from \"lucide-react\";\nimport { TextMorph } from \"../text-morph/text-morph\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./color-picker.module.css\";\n\nexport type ColorFormat = \"hex\" | \"rgb\" | \"hsl\" | \"oklch\";\nexport interface ColorSwatch { id: string; color: string }\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) => { const f = 10 ** digits; return Math.round(value * f) / f; };\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1, restDelta: .0005, restSpeed: .005 };\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(.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(.1, 0);\nconst openSpring = physical(spring.morph.visualDuration, .1), closeSpring = physical(.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) => { const k = (n + h / 60) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); };\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), min = Math.min(r, g, b), 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, 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), min = Math.min(r, g, b), l = (max + min) / 2, 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 <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;\nconst fromLinear = (c: number) => c <= .0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - .055;\nfunction rgbToOklch({ r, g, b }: Rgba, hue: number) {\n  const lr = toLinear(r), lg = toLinear(g), lb = toLinear(b);\n  const l = Math.cbrt(.4122214708 * lr + .5363325363 * lg + .0514459929 * lb);\n  const m = Math.cbrt(.2119034982 * lr + .6806995451 * lg + .1073969566 * lb);\n  const s = Math.cbrt(.0883024619 * lr + .2817188376 * lg + .6299787005 * lb);\n  const L = .2104542553 * l + .793617785 * m - .0040720468 * s;\n  const A = 1.9779984951 * l - 2.428592205 * m + .4505937099 * s;\n  const B = .0259040371 * l + .7827717662 * m - .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 < .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), B = C * Math.sin(H * Math.PI / 180);\n  const l = (L + .3963377774 * A + .2158037573 * B) ** 3;\n  const m = (L - .1055613458 * A - .0638541728 * B) ** 3;\n  const s = (L - .0894841775 * A - 1.291485548 * B) ** 3;\n  return {\n    r: clamp(fromLinear(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s)),\n    g: clamp(fromLinear(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s)),\n    b: clamp(fromLinear(-.0041960863 * l - .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 < .999 ? hex2(a) : \"\"}`;\n}\nfunction format(hsva: Hsva, kind: ColorFormat) {\n  const rgb = hsvToRgb(hsva);\n  const alpha = hsva.a < .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\") { const { h, s, l } = rgbToHsl(rgb, hsva.h); return `hsl(${Math.round(h) % 360} ${Math.round(s * 100)}% ${Math.round(l * 100)}%${alpha})`; }\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) => 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) digits = digits.split(\"\").map(d => d + d).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) return rgbToHsv({ r: clamp(+match[1]! / 255), g: clamp(+match[2]! / 255), b: clamp(+match[3]! / 255), a: readAlpha(match[4]) }, hue);\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) => .2126 * toLinear(r) + .7152 * toLinear(g) + .0722 * toLinear(b);\n  const a = lum(mix(fg.r, background.r), mix(fg.g, background.g), mix(fg.b, background.b)), b = lum(background.r, background.g, background.b);\n  return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);\n}\nconst level = (ratio: number) => ratio >= 7 ? \"AAA\" : ratio >= 4.5 ? \"AA\" : ratio >= 3 ? \"AA large\" : \"Fails\";\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() { return useSyncExternalStore(subscribe, () => true, () => false); }\nfunction useEyeDropper() { return useSyncExternalStore(subscribe, () => \"EyeDropper\" in window, () => false); }\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) { value.jump(target); return; }\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>) { if (event.pointerId === pointer.current) read(event); },\n    onPointerUp(event: ReactPointerEvent<HTMLElement>) { if (event.pointerId !== pointer.current) return; pointer.current = null; onActive(false); },\n    onPointerCancel(event: ReactPointerEvent<HTMLElement>) { if (event.pointerId !== pointer.current) return; pointer.current = null; onActive(false); },\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], ArrowLeft: [-size, 0], ArrowUp: axis === \"both\" ? [0, size] : [size, 0], ArrowDown: axis === \"both\" ? [0, -size] : [-size, 0],\n    PageUp: [10, 0], PageDown: [-10, 0],\n  };\n  return map[event.key] ?? null;\n}\n\ninterface SliderProps { label: string; value: number; valueText: string; max: number; unit: number; onChange: (value: number) => void; className?: string; style?: CSSProperties; reduced: boolean; fill: string; onActive: (active: boolean) => void; active: boolean }\nfunction Slider({ label, value, valueText, max, unit, onChange, className, 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 <div className={`${styles.slider} ${className}`} style={style} {...pad}>\n    <motion.div className={styles.sliderThumb} role=\"slider\" tabIndex={0} aria-label={label} aria-valuemin={0} aria-valuemax={max} aria-valuenow={Math.round(value)} aria-valuetext={valueText}\n      style={{ left, \"--thumb-fill\": fill } as unknown as CSSProperties} data-active={active || undefined}\n      onKeyDown={event => {\n        if (event.key === \"Home\" || event.key === \"End\") { event.preventDefault(); onChange(event.key === \"Home\" ? 0 : max); return; }\n        const step = stepFor(event, \"x\");\n        if (!step) return;\n        event.preventDefault();\n        onChange(clamp(value + step[0] * unit, 0, max));\n      }} />\n  </div>;\n}\n\nfunction Chip({ color, className }: { color: string; className?: string }) {\n  return <span className={`${styles.chip} ${className ?? \"\"}`}><span style={{ background: color }} /></span>;\n}\n\nlet swatchCount = 0;\nconst newId = () => `swatch-${Date.now().toString(36)}-${swatchCount++}`;\n\nexport function ColorPicker({\n  value, defaultValue = \"#2F6BFF\", onValueChange, background = \"#FFFFFF\", label = \"Color\", swatches: swatchesProp, defaultSwatches, onSwatchesChange,\n  maxSwatches = 7, defaultFormat = \"hex\", 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: .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)) { const next = parseColor(value, hsva.h); if (next) setHsva(next); }\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[]) { if (!swatchesProp) setOwnSwatches(next); onSwatchesChange?.(next); }\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), 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, [.35, .9], [0, 1]);\n  const contentY = useTransform(progress, [0, 1], [-10, 0]);\n\n  useLayoutEffect(() => {\n    if (!shown) return;\n    const panel = panelRef.current, trigger = triggerRef.current;\n    if (!panel || !trigger) return;\n    const measure = () => { size.W.set(panel.offsetWidth); size.H.set(panel.offsetHeight); size.w.set(trigger.offsetWidth); size.h.set(trigger.offsetHeight); };\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      controls.then(() => { if (!openNow.current) { progress.jump(0); setShown(false); } });\n      return () => controls.stop();\n    }\n    fade.jump(1);\n    const controls = animate(progress, open ? 1 : 0, open ? openSpring : closeSpring);\n    if (!open) controls.then(() => { if (!openNow.current) setShown(false); });\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() { openNow.current = true; setShown(true); setOpen(true); }\n  function hide(returnFocus = true) {\n    openNow.current = false;\n    setOpen(false); setDraft(null); 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 () => { document.removeEventListener(\"pointerdown\", listener, true); document.removeEventListener(\"keydown\", listener); };\n  }, [open]);\n\n  // The saturation and brightness area.\n  const areaX = useFollow(hsva.s, reduced, active === \"area\"), areaY = useFollow(1 - hsva.v, reduced, active === \"area\");\n  const areaLeft = useTransform(areaX, v => `${v * 100}%`), areaTop = useTransform(areaY, v => `${v * 100}%`);\n  const areaPad = usePad((x, y) => commit({ ...hsva, s: x, v: 1 - y }), on => setActive(on ? \"area\" : null));\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(() => setMorph(current => current && current.id === morph.id ? { ...current, phase: 1 } : current));\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); setDraft(null); 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: .32, ease: [...ease.standard] });\n      return false;\n    }\n    commit(next); setDraft(null); 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().open().then(result => { const next = parseColor(result.sRGBHex, hsva.h); if (next) commit({ ...next, a: hsva.a }); }).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(() => neighbor ? swatchRefs.current.get(neighbor.id)?.focus({ preventScroll: true }) : inputRef.current?.focus({ preventScroll: true }));\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); next.splice(to, 0, item);\n      setSwatches(next);\n      requestAnimationFrame(() => swatchRefs.current.get(item.id)?.focus({ preventScroll: true }));\n      return;\n    }\n    const target = event.key === \"Home\" ? 0 : event.key === \"End\" ? swatches.length - 1 : (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, v => v.toFixed(2));\n  const grade = level(ratio);\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 = { \"--picker-color\": css, \"--picker-opaque\": opaque, \"--picker-hue\": pure, \"--picker-bg\": background } as CSSProperties;\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} style={style}>\n    <button ref={triggerRef} type=\"button\" className={styles.trigger} aria-haspopup=\"dialog\" aria-expanded={open} aria-controls={open ? `${uid}-panel` : undefined}\n      onClick={() => open ? hide() : show()}>\n      <Chip color={css} />\n      <span className={styles.triggerText}><span className={styles.name}>{label}</span><span className={styles.hex}>{hex}</span></span>\n    </button>\n\n    {shown && <div className={styles.float} data-open={open || undefined}>\n      <motion.div ref={panelRef} id={`${uid}-panel`} role=\"dialog\" aria-label={`${label} color`} className={styles.panel} inert={!open} style={{ clipPath, opacity: fade }}>\n        <div className={styles.head}>\n          <Chip color={css} />\n          <span className={styles.triggerText}><span className={styles.name}>{label}</span><span className={styles.hex}>{hex}</span></span>\n          <motion.span className={styles.headActions} style={{ opacity: contentOpacity }}>\n            {canPick && <button type=\"button\" className={styles.iconButton} aria-label=\"Pick a color from the screen\" onClick={pickFromScreen}><Pipette size={18} strokeWidth={1.75} aria-hidden=\"true\" /></button>}\n            <button type=\"button\" className={styles.iconButton} aria-label=\"Done\" onClick={() => hide()}><Check size={18} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n          </motion.span>\n        </div>\n\n        <motion.div className={styles.body} style={{ opacity: contentOpacity, y: contentY }}>\n          <div className={styles.area} data-active={active === \"area\" || undefined} {...areaPad}>\n            <motion.div ref={areaThumb} className={styles.areaThumb} role=\"slider\" tabIndex={0} aria-roledescription=\"2D slider\"\n              aria-label=\"Saturation and brightness\" aria-valuemin={0} aria-valuemax={100} 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          </div>\n\n          <Slider label=\"Hue\" value={hsva.h} valueText={`${Math.round(hsva.h)} degrees`} max={360} unit={1} className={styles.hue} reduced={reduced} fill={pure}\n            onChange={h => commit({ ...hsva, h: clamp(h, 0, 359.9) })} active={active === \"hue\"} onActive={on => setActive(on ? \"hue\" : null)} />\n          <Slider label=\"Opacity\" value={hsva.a * 100} valueText={`${Math.round(hsva.a * 100)}%`} max={100} unit={1} className={styles.alpha} reduced={reduced} fill={css}\n            onChange={a => commit({ ...hsva, a: clamp(a / 100) })} active={active === \"alpha\"} onActive={on => setActive(on ? \"alpha\" : null)} />\n\n          <motion.div className={styles.field} data-invalid={invalid || undefined} style={{ x: fieldX }}>\n            <button type=\"button\" className={styles.format} onClick={cycleFormat} aria-label={`Format: ${formatNames[kind]}. Switch format`}>\n              <TextMorph>{formatNames[kind]}</TextMorph>\n            </button>\n            <span className={styles.inputWrap}>\n              <input ref={inputRef} className={styles.input} value={draft ?? text} spellCheck={false} autoComplete=\"off\" aria-label={`${label} in ${formatNames[kind]}`}\n                aria-invalid={invalid || undefined} aria-describedby={invalid ? `${uid}-error` : undefined} data-morphing={morph ? \"\" : undefined}\n                onChange={event => { setDraft(event.target.value); setInvalid(false); }}\n                onKeyDown={event => {\n                  if (event.key === \"Enter\") { event.preventDefault(); if (submitDraft()) event.currentTarget.select(); }\n                  if (event.key === \"Escape\" && draft !== null) { event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); setDraft(null); setInvalid(false); }\n                }}\n                onBlur={() => { if (!submitDraft()) { setDraft(null); setInvalid(false); } }} />\n              {morph && <span className={styles.morph} aria-hidden=\"true\"><TextMorph>{morph.phase === 0 ? morph.from : morph.to}</TextMorph></span>}\n            </span>\n          </motion.div>\n          <p id={`${uid}-error`} className={styles.error} aria-live=\"polite\">{invalid ? \"Enter a hex, RGB, HSL, or OKLCH color\" : \"\"}</p>\n\n          <div className={styles.contrast}>\n            <span className={styles.sample} aria-hidden=\"true\">Aa</span>\n            <span className={styles.ratio}><motion.span>{ratioText}</motion.span>:1</span>\n            <span className={styles.against}>against background</span>\n            <span className={styles.grade} data-grade={grade === \"Fails\" ? \"fail\" : grade === \"AA large\" ? \"large\" : \"pass\"}>\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span key={grade} initial={hidden} animate={{ opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n                  exit={{ ...hidden, transition: { duration: duration.instant } }} transition={reduced ? { duration: duration.instant } : { duration: duration.standard, ease: [...ease.enter] }}>{grade}</motion.span>\n              </AnimatePresence>\n            </span>\n            <span className={styles.srOnly}>{`Contrast ${ratio.toFixed(2)} to 1, ${grade === \"Fails\" ? \"fails\" : `passes ${grade}`}`}</span>\n          </div>\n\n          <div className={styles.swatches}>\n            <button type=\"button\" className={styles.add} onClick={saveSwatch} aria-label={`Save ${hex}`}><Plus size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n            <Reorder.Group as=\"div\" axis=\"x\" values={swatches} onReorder={setSwatches} className={styles.swatchList} role=\"listbox\" aria-label=\"Saved colors\" aria-orientation=\"horizontal\">\n              <AnimatePresence initial={false}>\n                {swatches.map((entry, index) => <Reorder.Item key={entry.id} value={entry} as=\"div\" className={styles.swatchSlot} dragElastic={.12}\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .4 }} animate={{ opacity: 1, scale: 1 }}\n                  exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .4, transition: { duration: duration.fast } }}\n                  transition={reduced ? instant : { ...spring.snappy, layout: spring.smooth }} whileDrag={reduced ? undefined : { scale: 1.15, zIndex: 2 }}\n                  onDragStart={() => { dragged.current = true; }} onDragEnd={() => requestAnimationFrame(() => { dragged.current = false; })}>\n                  <button ref={node => { if (node) swatchRefs.current.set(entry.id, node); else swatchRefs.current.delete(entry.id); }}\n                    type=\"button\" role=\"option\" aria-selected={entry.color.toUpperCase() === hex} className={styles.swatch} data-current={entry.color.toUpperCase() === hex || undefined}\n                    aria-label={`${entry.color}. Alt and arrow keys to move, Delete to remove`} tabIndex={entry.id === swatchTab ? 0 : -1}\n                    onFocus={() => setFocusSwatch(entry.id)} onKeyDown={event => onSwatchKey(event, index)}\n                    onClick={() => { if (dragged.current) return; const next = parseColor(entry.color, hsva.h); if (next) commit(next); }}>\n                    <Chip color={entry.color} className={styles.swatchChip} />\n                  </button>\n                </Reorder.Item>)}\n              </AnimatePresence>\n            </Reorder.Group>\n          </div>\n        </motion.div>\n      </motion.div>\n    </div>}\n  </div>;\n}\n\nexport default ColorPicker;\n"
    },
    {
      "path": "registry/components/color-picker/color-picker.module.css",
      "type": "registry:component",
      "target": "@components/xzero/color-picker/color-picker.module.css",
      "content": "/* 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.root {\n  --picker-fill: var(--surface);\n  --picker-width: min(19rem, calc(100vw - 32px));\n  --checker: repeating-conic-gradient(oklch(88% 0 0) 0 25%, oklch(100% 0 0) 0 50%) 0 0 / 10px 10px;\n  position: relative;\n  display: inline-block;\n  font-family: var(--font-body);\n  letter-spacing: var(--tracking-body);\n  color: var(--foreground);\n}\n:global(:root[data-theme=\"dark\"]) .root { --picker-fill: var(--surface-raised); --checker: repeating-conic-gradient(oklch(42% 0 0) 0 25%, oklch(30% 0 0) 0 50%) 0 0 / 10px 10px; }\n\n.trigger, .head { display: flex; height: 44px; align-items: center; gap: 10px; padding: 6px 16px 6px 6px; }\n.trigger {\n  border: 1px solid var(--border); border-radius: 22px; background: var(--picker-fill); box-shadow: var(--shadow-resting); color: inherit; font: inherit; cursor: pointer;\n  -webkit-tap-highlight-color: transparent; transition: border-color var(--duration-fast) var(--ease-standard);\n}\n.triggerText { display: grid; min-width: 0; text-align: start; line-height: 1.15; }\n.name { color: var(--text-secondary); font-size: var(--text-xs); }\n.hex { font-size: var(--text-sm); font-variant-numeric: tabular-nums; font-weight: 500; }\n\n/* A chip over a checkerboard, so opacity reads as opacity. */\n.chip { position: relative; display: block; width: 32px; height: 32px; flex: none; overflow: hidden; border-radius: 50%; background: var(--checker); }\n.chip > span { position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .1); }\n\n/* The floating layer. Its drop shadow follows the clipped shape, so the shadow grows with the panel. */\n.float { position: absolute; z-index: 20; top: -1px; left: -1px; filter: drop-shadow(0 18px 36px oklch(0% 0 0 / .12)) drop-shadow(0 2px 6px oklch(0% 0 0 / .06)); pointer-events: none; }\n:global(:root[data-theme=\"dark\"]) .float { filter: drop-shadow(0 20px 44px oklch(0% 0 0 / .5)); }\n.float[data-open] { pointer-events: auto; }\n.panel { width: var(--picker-width); padding-bottom: 14px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--picker-fill); will-change: clip-path; }\n.head { gap: 10px; padding-right: 6px; }\n.head .triggerText { flex: 1; }\n.headActions { display: flex; gap: 2px; }\n.iconButton {\n  display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary); cursor: pointer;\n  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);\n}\n.iconButton:active { transform: scale(.94); }\n/* One column locked to the panel: a wider grade label (\"AA large\") must never widen the area and sliders mid drag. */\n.body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; padding: 8px 14px 0; }\n\n/* Saturation runs left to right, brightness bottom to top, over the pure hue. */\n.area {\n  position: relative; height: 164px; border-radius: 16px; cursor: crosshair; touch-action: none;\n  background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), var(--picker-hue);\n  box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .08);\n}\n.areaThumb, .sliderThumb {\n  position: absolute; width: 22px; height: 22px; border-radius: 50%; translate: -50% -50%; cursor: grab;\n  box-shadow: 0 0 0 3px #fff, 0 2px 8px oklch(0% 0 0 / .3), inset 0 0 0 1px oklch(0% 0 0 / .12);\n  transition: scale var(--duration-spring) var(--ease-spring);\n}\n.areaThumb { background: var(--picker-opaque); }\n.area[data-active] .areaThumb, .sliderThumb[data-active] { scale: 1.2; cursor: grabbing; }\n\n.slider { position: relative; height: 14px; margin: 0 11px; border-radius: var(--radius-pill); cursor: pointer; touch-action: none; }\n.slider::before { content: \"\"; position: absolute; inset: 0 -11px; border-radius: inherit; background: var(--track); box-shadow: inset 0 0 0 1px oklch(0% 0 0 / .08); }\n.sliderThumb { top: 50%; background: var(--checker); }\n.sliderThumb::after { content: \"\"; position: absolute; inset: 0; border-radius: inherit; background: var(--thumb-fill); }\n/* The gradient stops sit at the thumb's travel ends, so the color under the thumb is the value it reports. */\n.hue { --track: 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)); }\n.alpha { --track: linear-gradient(to right, transparent 11px, var(--picker-opaque) calc(100% - 11px)), var(--checker); }\n\n.field { display: flex; height: 40px; align-items: stretch; overflow: hidden; margin-top: 2px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-muted); transition: border-color var(--duration-fast) var(--ease-standard); }\n.field[data-invalid] { border-color: var(--danger); }\n.format {\n  display: flex; min-width: 56px; align-items: center; justify-content: center; padding: 0 10px; border: 0; border-right: 1px solid var(--border); background: transparent;\n  color: var(--foreground); font: inherit; font-size: var(--text-xs); font-weight: 500; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard);\n}\n.inputWrap { position: relative; display: flex; min-width: 0; flex: 1; }\n.input, .morph { padding: 0 10px; font: inherit; font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.input { width: 100%; min-width: 0; border: 0; background: transparent; color: var(--foreground); caret-color: var(--foreground); }\n.input[data-morphing] { color: transparent; }\n.morph { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: var(--foreground); pointer-events: none; white-space: nowrap; }\n.error { min-height: 0; margin: -10px 2px -4px; color: var(--danger); font-size: var(--text-xs); }\n.error:empty { display: none; }\n\n.contrast { display: flex; align-items: center; gap: 10px; font-size: var(--text-sm); }\n.sample { display: grid; width: 36px; height: 28px; flex: none; place-items: center; border: 1px solid var(--border); border-radius: 9px; background: var(--picker-bg); color: var(--picker-color); font-weight: 500; }\n.ratio { font-variant-numeric: tabular-nums; font-weight: 500; min-width: 4.4ch; }\n.against { flex: 1; min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }\n.grade { position: relative; display: inline-grid; height: 22px; align-items: center; padding: 0 8px; border-radius: var(--radius-pill); font-size: var(--text-xs); font-weight: 500; white-space: nowrap; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }\n.grade[data-grade=\"pass\"] { background: color-mix(in oklab, var(--success) 14%, transparent); color: var(--success); }\n.grade[data-grade=\"large\"] { background: color-mix(in oklab, var(--warning) 16%, transparent); color: var(--warning); }\n.grade[data-grade=\"fail\"] { background: color-mix(in oklab, var(--danger) 12%, transparent); color: var(--danger); }\n\n.swatches { display: flex; align-items: center; gap: 6px; min-height: 32px; }\n.add {\n  display: grid; width: 30px; height: 30px; flex: none; place-items: center; padding: 0; border: 1px dashed var(--border-strong); border-radius: 50%; background: transparent; color: var(--text-secondary); cursor: pointer;\n  transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);\n}\n.add:active { transform: scale(.92); }\n.swatchList { display: flex; min-width: 0; flex: 1; gap: 6px; }\n.swatchSlot { position: relative; flex: none; touch-action: pan-y; }\n.swatch { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; }\n.swatchChip { width: 26px; height: 26px; transition: scale var(--duration-spring) var(--ease-spring); }\n.swatch[data-current] { box-shadow: inset 0 0 0 2px var(--foreground); }\n.swatch[data-current] .swatchChip { scale: .78; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (hover: hover) and (pointer: fine) {\n  .trigger:hover { border-color: var(--border-strong); }\n  .iconButton:hover, .format:hover { background: var(--surface-muted); color: var(--foreground); }\n  .format:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n  .add:hover { border-color: var(--foreground); color: var(--foreground); }\n}\n@media (max-width: 360px) { .against { display: none; } }\n@media (prefers-contrast: more) { .trigger, .panel, .field { border-color: var(--border-strong); } }\n@media (prefers-reduced-motion: reduce) { .areaThumb, .sliderThumb, .swatchChip, .iconButton, .add { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/color-picker",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/color-picker",
    "markdown": "https://ui.xzero.co.uk/components/color-picker/markdown"
  }
}