{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "color-picker",
  "title": "Color Picker",
  "author": "Matthew Blode",
  "description": "A saturation area, hue slider, hex input and suggested swatches for choosing a color.",
  "dependencies": ["@base-ui/react", "blode-icons-react"],
  "registryDependencies": ["button", "input", "popover", "separator"],
  "files": [
    {
      "path": "ui/color-picker.tsx",
      "content": "\"use client\";\n\nimport { Slider as SliderPrimitive } from \"@base-ui/react/slider\";\nimport { CheckIcon, EyedropperIcon } from \"blode-icons-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { Separator } from \"@/components/ui/separator\";\n\n/** A named colour offered in the suggested grid. */\nexport interface ColorSwatch {\n  /** Human-readable name, used as the swatch's accessible name. */\n  name: string;\n  /** Hex value, e.g. `#D9544B`. */\n  value: string;\n}\n\n/**\n * Six neutrals and a twelve-step spectrum held at a constant OKLCH lightness\n * and chroma, so no single hue jumps forward in the grid. These are data, not\n * theme tokens: the colour a user picks belongs to their content, not to Blode.\n */\nconst defaultColorSwatches: ColorSwatch[] = [\n  { name: \"Black\", value: \"#0A0A0A\" },\n  { name: \"Graphite\", value: \"#333333\" },\n  { name: \"Slate\", value: \"#636363\" },\n  { name: \"Silver\", value: \"#989898\" },\n  { name: \"Mist\", value: \"#D7D7D7\" },\n  { name: \"White\", value: \"#FFFFFF\" },\n  { name: \"Red\", value: \"#D9544B\" },\n  { name: \"Orange\", value: \"#CF6400\" },\n  { name: \"Amber\", value: \"#B27C00\" },\n  { name: \"Lime\", value: \"#809100\" },\n  { name: \"Green\", value: \"#24A042\" },\n  { name: \"Teal\", value: \"#00A584\" },\n  { name: \"Cyan\", value: \"#009FB9\" },\n  { name: \"Azure\", value: \"#0091DE\" },\n  { name: \"Blue\", value: \"#587EEB\" },\n  { name: \"Violet\", value: \"#936BDE\" },\n  { name: \"Magenta\", value: \"#BA5BBB\" },\n  { name: \"Rose\", value: \"#D25188\" },\n];\n\nconst SWATCH_COLUMNS = 6;\nconst HEX_PATTERN = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu;\nconst SRGB_LINEAR_CUTOFF = 0.04045;\nconst MAX_HUE = 360;\nconst HUE_SECTOR = 60;\nconst PERCENT = 100;\n\n/** Expands `#RGB` to `#RRGGBB` and upper-cases it. Returns null when unparseable. */\nconst normalizeHex = (input: string): string | null => {\n  const trimmed = input.trim();\n\n  if (!HEX_PATTERN.test(trimmed)) {\n    return null;\n  }\n\n  const digits = trimmed.replace(\"#\", \"\");\n  const expanded =\n    digits.length === 3 ? [...digits].map((digit) => digit + digit).join(\"\") : digits;\n\n  return `#${expanded.toUpperCase()}`;\n};\n\nconst toChannels = (hex: string): [number, number, number] => {\n  const digits = hex.replace(\"#\", \"\");\n\n  return [0, 2, 4].map((offset) => Number.parseInt(digits.slice(offset, offset + 2), 16) / 255) as [\n    number,\n    number,\n    number,\n  ];\n};\n\n/**\n * Near-black or near-white ink for the selected marker, chosen from the\n * swatch's own luminance. The swatch is arbitrary user data, so no token can\n * know which way to go.\n */\nconst readableInk = (hex: string): string => {\n  const [red, green, blue] = toChannels(hex).map((channel) =>\n    channel <= SRGB_LINEAR_CUTOFF ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,\n  );\n\n  return 0.2126 * red + 0.7152 * green + 0.0722 * blue > 0.4 ? \"#0A0A0A\" : \"#FFFFFF\";\n};\n\n/** Hue in degrees, saturation and value as percentages. */\ninterface Hsv {\n  h: number;\n  s: number;\n  v: number;\n}\n\nconst hexToHsv = (hex: string): Hsv => {\n  const [red, green, blue] = toChannels(hex);\n  const max = Math.max(red, green, blue);\n  const min = Math.min(red, green, blue);\n  const span = max - min;\n\n  let sextant = 0;\n\n  if (span !== 0) {\n    if (max === red) {\n      sextant = (green - blue) / span;\n    } else if (max === green) {\n      sextant = (blue - red) / span + 2;\n    } else {\n      sextant = (red - green) / span + 4;\n    }\n  }\n\n  return {\n    h: (sextant * HUE_SECTOR + MAX_HUE) % MAX_HUE,\n    s: max === 0 ? 0 : (span / max) * PERCENT,\n    v: max * PERCENT,\n  };\n};\n\nconst hsvToHex = ({ h, s, v }: Hsv): string => {\n  const saturation = s / PERCENT;\n  const value = v / PERCENT;\n  const chroma = value * saturation;\n  const sector = (((h % MAX_HUE) + MAX_HUE) % MAX_HUE) / HUE_SECTOR;\n  const mid = chroma * (1 - Math.abs((sector % 2) - 1));\n  const ramps: [number, number, number][] = [\n    [chroma, mid, 0],\n    [mid, chroma, 0],\n    [0, chroma, mid],\n    [0, mid, chroma],\n    [mid, 0, chroma],\n    [chroma, 0, mid],\n  ];\n  const base = value - chroma;\n  const channels = ramps[Math.floor(sector) % 6].map((channel) =>\n    Math.round((channel + base) * 255)\n      .toString(16)\n      .padStart(2, \"0\"),\n  );\n\n  return `#${channels.join(\"\")}`.toUpperCase();\n};\n\nconst clamp01 = (input: number) => Math.min(1, Math.max(0, input));\n\ninterface EyeDropperResult {\n  sRGBHex: string;\n}\n\ntype EyeDropperConstructor = new () => {\n  open: (options?: { signal?: AbortSignal }) => Promise<EyeDropperResult>;\n};\n\nconst noop = () => {\n  // Nothing to unsubscribe from: the capability never changes at runtime.\n};\n\nconst noopSubscribe = () => noop;\n\n/**\n * Feature-detected through `useSyncExternalStore` rather than an effect, so the\n * server snapshot is `false` and hydration matches before the real answer\n * arrives on the client.\n */\nconst useHasEyeDropper = () =>\n  React.useSyncExternalStore(\n    noopSubscribe,\n    () => \"EyeDropper\" in window,\n    () => false,\n  );\n\n/**\n * The white ring alone disappears against the white corner of the saturation\n * area, so every thumb carries a dark outer ring as well.\n */\nconst THUMB_CLASSES =\n  \"pointer-events-none rounded-full border-2 border-white bg-transparent ring-1 ring-black/25 shadow-sm\";\n\nexport interface ColorPickerProps {\n  /** Accessible name for the trigger. The current value is appended to it. */\n  \"aria-label\"?: string;\n  /** Class names merged onto the trigger button. */\n  className?: string;\n  /** Disables the trigger and the whole popover. */\n  disabled?: boolean;\n  /** Id applied to the trigger button, for a `FieldLabel htmlFor`. */\n  id?: string;\n  /**\n   * Called once a gesture settles — pointer release, `Enter` or blur in the hex\n   * field, a swatch click. Use it for writes too expensive to run per frame.\n   */\n  onValueCommit?: (value: string) => void;\n  /** Called with a normalised `#RRGGBB` string whenever the colour changes. */\n  onValueChange?: (value: string) => void;\n  /** Swatches offered under “Suggested”. Defaults to Blode's neutral and spectrum set. */\n  swatches?: ColorSwatch[];\n  /** The selected colour as a hex string. */\n  value: string;\n}\n\nconst ColorPicker = ({\n  \"aria-label\": ariaLabel = \"Colour\",\n  className,\n  disabled = false,\n  id,\n  onValueChange,\n  onValueCommit,\n  swatches = defaultColorSwatches,\n  value,\n}: ColorPickerProps) => {\n  const parsedValue = normalizeHex(value);\n  // White is only a render fallback. Without the flag below an unparseable\n  // value looks like a deliberate white, hiding the caller's mistake.\n  const normalizedValue = parsedValue ?? \"#FFFFFF\";\n  const valueIsInvalid = parsedValue === null;\n  const hasEyeDropper = useHasEyeDropper();\n  const [open, setOpen] = React.useState(false);\n  // `null` means \"mirror the committed value\", so a new `value` prop shows up\n  // in the input without an effect syncing the two.\n  const [draft, setDraft] = React.useState<string | null>(null);\n  const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);\n  const swatchRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const areaRef = React.useRef<HTMLDivElement | null>(null);\n  const saturationRef = React.useRef<HTMLInputElement | null>(null);\n\n  // HSV is the working state, not a value derived per render. Black and white\n  // carry no hue, so re-deriving would snap the rail back to red the moment a\n  // user dragged into either corner.\n  const [hsv, setHsv] = React.useState<Hsv>(() => hexToHsv(normalizedValue));\n  const [syncedValue, setSyncedValue] = React.useState(normalizedValue);\n\n  if (syncedValue !== normalizedValue) {\n    setSyncedValue(normalizedValue);\n\n    // Only re-derive when the new colour is genuinely a different one; echoing\n    // our own hex back must not round-trip away the hue we are holding.\n    if (hsvToHex(hsv) !== normalizedValue) {\n      setHsv(hexToHsv(normalizedValue));\n    }\n  }\n\n  const selectedIndex = swatches.findIndex(\n    (swatch) => (normalizeHex(swatch.value) ?? swatch.value) === normalizedValue,\n  );\n  const activeIndex = focusedIndex ?? Math.max(0, selectedIndex);\n  const draftValue = draft ?? normalizedValue;\n  const draftIsInvalid = draftValue.trim() !== \"\" && normalizeHex(draftValue) === null;\n\n  const applyHsv = (next: Hsv) => {\n    const hex = hsvToHex(next);\n\n    setHsv(next);\n    setSyncedValue(hex);\n    setDraft(null);\n    onValueChange?.(hex);\n\n    return hex;\n  };\n\n  const commitHsv = (next: Hsv) => {\n    onValueCommit?.(applyHsv(next));\n  };\n\n  const applyHex = (hex: string) => {\n    const next = hexToHsv(hex);\n\n    setHsv(next);\n    setSyncedValue(hex);\n    setDraft(null);\n    onValueChange?.(hex);\n    onValueCommit?.(hex);\n  };\n\n  const focusSwatch = (index: number) => {\n    const clamped = Math.max(0, Math.min(swatches.length - 1, index));\n\n    setFocusedIndex(clamped);\n    swatchRefs.current[clamped]?.focus();\n  };\n\n  const handleSwatchKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {\n    const moves: Record<string, number> = {\n      ArrowDown: index + SWATCH_COLUMNS,\n      ArrowLeft: index - 1,\n      ArrowRight: index + 1,\n      ArrowUp: index - SWATCH_COLUMNS,\n      End: swatches.length - 1,\n      Home: 0,\n    };\n    const next = moves[event.key];\n\n    if (next === undefined) {\n      return;\n    }\n\n    event.preventDefault();\n    focusSwatch(next);\n  };\n\n  const commitDraft = () => {\n    const normalized = normalizeHex(draftValue);\n\n    // An unparseable entry reverts rather than clearing the colour, so a\n    // half-typed hex never destroys the caller's value.\n    setDraft(null);\n\n    if (normalized) {\n      applyHex(normalized);\n    }\n  };\n\n  const pickFromScreen = async () => {\n    const { EyeDropper } = window as unknown as { EyeDropper?: EyeDropperConstructor };\n\n    if (!EyeDropper) {\n      return;\n    }\n\n    try {\n      const result = await new EyeDropper().open();\n      const normalized = normalizeHex(result.sRGBHex);\n\n      if (normalized) {\n        applyHex(normalized);\n      }\n    } catch {\n      // The picker was dismissed; the current colour stands.\n    }\n  };\n\n  const trackPointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = areaRef.current?.getBoundingClientRect();\n\n    if (!rect) {\n      return hsv;\n    }\n\n    const next = {\n      h: hsv.h,\n      s: clamp01((event.clientX - rect.left) / rect.width) * PERCENT,\n      v: (1 - clamp01((event.clientY - rect.top) / rect.height)) * PERCENT,\n    };\n\n    applyHsv(next);\n\n    return next;\n  };\n\n  const hueColor = hsvToHex({ h: hsv.h, s: PERCENT, v: PERCENT });\n  const areaValueText = `${Math.round(hsv.s)}% saturation, ${Math.round(hsv.v)}% brightness, ${normalizedValue}`;\n\n  return (\n    <Popover onOpenChange={setOpen} open={open}>\n      <PopoverTrigger\n        aria-invalid={valueIsInvalid || undefined}\n        aria-label={\n          valueIsInvalid ? `${ariaLabel}, invalid value` : `${ariaLabel}, ${normalizedValue}`\n        }\n        data-invalid={valueIsInvalid || undefined}\n        disabled={disabled}\n        id={id}\n        render={<Button className={cn(\"justify-start\", className)} size=\"input\" variant=\"input\" />}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"size-5 shrink-0 rounded-sm inset-ring-1 inset-ring-foreground/15\"\n          data-slot=\"color-picker-swatch\"\n          style={{ backgroundColor: normalizedValue }}\n        />\n        <span className=\"tabular-figures text-sm\">{normalizedValue}</span>\n      </PopoverTrigger>\n\n      <PopoverContent\n        align=\"start\"\n        className=\"flex w-64 flex-col gap-3 p-3\"\n        data-slot=\"color-picker-content\"\n      >\n        {/* Two hidden range inputs rather than a keydown handler on the div:\n            each axis then carries real slider semantics, and arrow keys, Home,\n            End and Page Up/Down all come from the platform. */}\n        {/* oxlint-disable jsx-a11y/prefer-tag-over-role -- a <fieldset> is the rule's suggested tag, but its anonymous content box makes the thumb's percentage `top` resolve against an indefinite height, pinning the thumb to 0 while `left` resolves normally */}\n        <div\n          aria-label={`${ariaLabel} saturation and brightness`}\n          className=\"relative h-40 w-full touch-none select-none rounded-sm inset-ring-1 inset-ring-foreground/10\"\n          data-slot=\"color-picker-area\"\n          role=\"group\"\n          onPointerDown={(event) => {\n            if (disabled) {\n              return;\n            }\n\n            // Suppressing the compatibility mousedown stops the popup from\n            // pulling focus onto itself, which would undo the focus() below.\n            event.preventDefault();\n            event.currentTarget.setPointerCapture(event.pointerId);\n            // The gradient is not itself focusable, so without this a drag\n            // leaves focus behind and arrow keys cannot continue the gesture.\n            saturationRef.current?.focus();\n            trackPointer(event);\n          }}\n          onPointerMove={(event) => {\n            if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n              trackPointer(event);\n            }\n          }}\n          onPointerUp={(event) => {\n            event.currentTarget.releasePointerCapture(event.pointerId);\n            commitHsv(trackPointer(event));\n          }}\n          ref={areaRef}\n          style={{\n            backgroundColor: hueColor,\n            backgroundImage:\n              \"linear-gradient(0deg, #000000, transparent), linear-gradient(90deg, #FFFFFF, transparent)\",\n          }}\n        >\n          <input\n            aria-label=\"Saturation\"\n            aria-valuetext={areaValueText}\n            className=\"peer/saturation sr-only\"\n            disabled={disabled}\n            max={PERCENT}\n            min={0}\n            onBlur={() => onValueCommit?.(hsvToHex(hsv))}\n            onChange={(event) => applyHsv({ ...hsv, s: event.target.valueAsNumber })}\n            ref={saturationRef}\n            step={1}\n            type=\"range\"\n            value={Math.round(hsv.s)}\n          />\n          <input\n            aria-label=\"Brightness\"\n            aria-valuetext={areaValueText}\n            className=\"peer/brightness sr-only\"\n            disabled={disabled}\n            max={PERCENT}\n            min={0}\n            onChange={(event) => applyHsv({ ...hsv, v: event.target.valueAsNumber })}\n            onBlur={() => onValueCommit?.(hsvToHex(hsv))}\n            step={1}\n            type=\"range\"\n            value={Math.round(hsv.v)}\n          />\n          <span\n            className={cn(\n              THUMB_CLASSES,\n              \"absolute size-5 -translate-x-1/2 -translate-y-1/2 peer-focus-visible/brightness:outline-2 peer-focus-visible/brightness:outline-ring peer-focus-visible/brightness:outline-offset-2 peer-focus-visible/saturation:outline-2 peer-focus-visible/saturation:outline-ring peer-focus-visible/saturation:outline-offset-2\",\n            )}\n            data-slot=\"color-picker-area-thumb\"\n            style={{ left: `${hsv.s}%`, top: `${PERCENT - hsv.v}%` }}\n          />\n        </div>\n        {/* oxlint-enable jsx-a11y/prefer-tag-over-role */}\n\n        <SliderPrimitive.Root\n          disabled={disabled}\n          largeStep={10}\n          max={MAX_HUE}\n          min={0}\n          onValueChange={(next) => applyHsv({ ...hsv, h: next as number })}\n          onValueCommitted={(next) => commitHsv({ ...hsv, h: next as number })}\n          step={1}\n          value={Math.round(hsv.h)}\n        >\n          <SliderPrimitive.Control\n            className=\"relative flex h-6 w-full touch-none select-none items-center data-disabled:opacity-50\"\n            data-slot=\"color-picker-hue\"\n          >\n            {/* Literal sRGB stops: this is the hue wheel itself, not chrome, so\n                no theme token can stand in for it. */}\n            <SliderPrimitive.Track className=\"relative h-3 w-full rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)] inset-ring-1 inset-ring-foreground/10\" />\n            <SliderPrimitive.Thumb\n              aria-label=\"Hue\"\n              className={cn(\n                THUMB_CLASSES,\n                \"pointer-events-auto size-6 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2\",\n              )}\n              data-slot=\"color-picker-hue-thumb\"\n              style={{ backgroundColor: hueColor }}\n            />\n          </SliderPrimitive.Control>\n        </SliderPrimitive.Root>\n\n        <div className=\"flex items-center gap-2\" data-slot=\"color-picker-custom\">\n          <Input\n            aria-invalid={draftIsInvalid || undefined}\n            aria-label=\"Hex colour\"\n            autoComplete=\"off\"\n            className=\"tabular-figures h-9 rounded-lg text-sm\"\n            disabled={disabled}\n            maxLength={7}\n            onBlur={commitDraft}\n            onChange={(event) => setDraft(event.target.value)}\n            onKeyDown={(event) => {\n              if (event.key === \"Enter\") {\n                event.preventDefault();\n                commitDraft();\n              }\n            }}\n            spellCheck={false}\n            value={draftValue}\n          />\n          {hasEyeDropper && (\n            <Button\n              aria-label=\"Pick a colour from the screen\"\n              disabled={disabled}\n              onClick={pickFromScreen}\n              size=\"icon-sm\"\n              type=\"button\"\n              variant=\"outline\"\n            >\n              <EyedropperIcon />\n            </Button>\n          )}\n        </div>\n\n        {swatches.length > 0 && (\n          <>\n            <Separator />\n\n            <div className=\"flex flex-col gap-2\">\n              <span\n                className=\"text-muted-foreground text-xs\"\n                id={`${id ?? \"color-picker\"}-suggested`}\n              >\n                Suggested\n              </span>\n\n              {/* oxlint-disable jsx-a11y/prefer-tag-over-role -- role=\"radio\" on a button keeps grid arrow-keys (Up/Down move a row, not one swatch) and Enter/Space-to-select; a native radio group moves linearly and selects on focus, which would change the colour while merely browsing it */}\n              <div\n                aria-labelledby={`${id ?? \"color-picker\"}-suggested`}\n                className=\"grid grid-cols-6 gap-2\"\n                data-slot=\"color-picker-swatches\"\n                role=\"radiogroup\"\n              >\n                {swatches.map((swatch, index) => {\n                  const swatchValue = normalizeHex(swatch.value) ?? swatch.value;\n                  const isSelected = swatchValue === normalizedValue;\n\n                  return (\n                    <button\n                      aria-checked={isSelected}\n                      aria-label={swatch.name}\n                      className={cn(\n                        \"relative flex size-7 items-center justify-center rounded-sm outline-none inset-ring-1 inset-ring-foreground/15 transition-[box-shadow,transform] duration-150 ease-out hover:scale-105 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2\",\n                        isSelected && \"ring-2 ring-foreground ring-offset-2 ring-offset-popover\",\n                      )}\n                      disabled={disabled}\n                      key={`${swatch.name}-${swatch.value}`}\n                      // A swatch is a starting point to refine, not a final\n                      // answer, so selecting one leaves the popover open.\n                      onClick={() => applyHex(swatchValue)}\n                      onKeyDown={(event) => handleSwatchKeyDown(event, index)}\n                      ref={(node) => {\n                        swatchRefs.current[index] = node;\n                      }}\n                      role=\"radio\"\n                      style={{ backgroundColor: swatchValue }}\n                      tabIndex={index === activeIndex ? 0 : -1}\n                      type=\"button\"\n                    >\n                      {/* A ring alone would be the only cue on a light swatch, so the\n                          selected state also carries a mark. */}\n                      {isSelected && (\n                        <CheckIcon\n                          className=\"size-3.5\"\n                          data-slot=\"color-picker-swatch-indicator\"\n                          style={{ color: readableInk(swatchValue) }}\n                        />\n                      )}\n                    </button>\n                  );\n                })}\n              </div>\n              {/* oxlint-enable jsx-a11y/prefer-tag-over-role */}\n            </div>\n          </>\n        )}\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport { ColorPicker, defaultColorSwatches, hexToHsv, hsvToHex, normalizeHex };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
