{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker",
  "title": "Color Picker",
  "author": "@fma-ui/ui",
  "description": "An OKLCH color picker: lightness, chroma and hue sliders painted with the color's own gradients. Given a background (contrastWith), it shows the WCAG ratio, hatches the lightness that fails AA, marks the AA/AAA thresholds and offers \"Fix to AA/AAA\" buttons that jump to the nearest passing lightness keeping the hue. A grid of preset swatches (Tailwind's palette by default, or your own via swatches) sets the color in one click, hatching the presets that fail AA. ColorPicker is a swatch that opens it in a popover; ColorPickerPanel renders it inline.",
  "dependencies": [
    "cn@^0.4.0",
    "culori@^4.0.2",
    "lucide-react@^1.52.0",
    "react-aria-components@^1.21.1"
  ],
  "registryDependencies": [
    "@fma-ui/button",
    "@fma-ui/popover",
    "@fma-ui/color-contrast"
  ],
  "files": [
    {
      "path": "src/core/color-picker/color-picker.tsx",
      "content": "import { cn } from \"cn\";\nimport { formatHex } from \"culori\";\nimport { ContrastIcon } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n  Dialog,\n  DialogTrigger,\n  Label,\n  ListBox,\n  ListBoxItem,\n  Button as RACButton,\n  Slider,\n  SliderOutput,\n  SliderThumb,\n  SliderTrack,\n} from \"react-aria-components\";\nimport { Button } from \"@/core/button/button\";\nimport { Popover } from \"@/core/popover/popover\";\nimport {\n  CONTRAST_AA,\n  CONTRAST_AAA,\n  contrastLevel,\n  contrastRatio,\n  formatOklchChannels,\n  inGamut,\n  nearestContrastColor,\n  type OklchChannels,\n  passingLightnessRanges,\n  toOklchChannels,\n} from \"@/lib/color-contrast\";\n\ntype Channel = \"l\" | \"c\" | \"h\";\n\nconst CHANNELS: Record<\n  Channel,\n  { label: string; min: number; max: number; step: number; format: (n: number) => string }\n> = {\n  l: { label: \"Lightness\", min: 0, max: 1, step: 0.001, format: (n) => n.toFixed(3) },\n  c: { label: \"Chroma\", min: 0, max: 0.4, step: 0.001, format: (n) => n.toFixed(3) },\n  h: { label: \"Hue\", min: 0, max: 360, step: 1, format: (n) => `${Math.round(n)}°` },\n};\n\nconst GRADIENT_STOPS = 24;\n\n/** Opaque sRGB hex of the channels (chroma clamped), for painting tracks and swatches. */\nfunction paint(channels: OklchChannels): string {\n  const { l, c, h } = inGamut(channels);\n  return formatHex({ mode: \"oklch\", l, c, h });\n}\n\nfunction trackGradient(channels: OklchChannels, channel: Channel): string {\n  const { min, max } = CHANNELS[channel];\n  const stops = Array.from({ length: GRADIENT_STOPS + 1 }, (_, i) => {\n    const value = min + ((max - min) * i) / GRADIENT_STOPS;\n    return paint({ ...channels, [channel]: value, alpha: 1 });\n  });\n  return `linear-gradient(to right, ${stops.join(\", \")})`;\n}\n\nconst HATCH =\n  \"bg-[repeating-linear-gradient(135deg,rgb(0_0_0/0.35)_0_2px,rgb(255_255_255/0.35)_2px_4px)]\";\n\n/** A preset color: a CSS color, or one with a name used as its accessible label. */\ntype Swatch = string | { color: string; name: string };\n\nconst TAILWIND_HUES = [\n  \"neutral\",\n  \"red\",\n  \"orange\",\n  \"amber\",\n  \"yellow\",\n  \"green\",\n  \"teal\",\n  \"cyan\",\n  \"blue\",\n  \"indigo\",\n  \"violet\",\n  \"pink\",\n];\nconst TAILWIND_SHADES: Array<[number, string[]]> = [\n  [\n    200,\n    [\n      \"0.922 0 0\",\n      \"0.885 0.062 18.334\",\n      \"0.901 0.076 70.697\",\n      \"0.924 0.12 95.746\",\n      \"0.945 0.129 101.54\",\n      \"0.925 0.084 155.995\",\n      \"0.91 0.096 180.426\",\n      \"0.917 0.08 205.041\",\n      \"0.882 0.059 254.128\",\n      \"0.87 0.065 274.039\",\n      \"0.894 0.057 293.283\",\n      \"0.899 0.061 343.231\",\n    ],\n  ],\n  [\n    400,\n    [\n      \"0.708 0 0\",\n      \"0.704 0.191 22.216\",\n      \"0.75 0.183 55.934\",\n      \"0.828 0.189 84.429\",\n      \"0.852 0.199 91.936\",\n      \"0.792 0.209 151.711\",\n      \"0.777 0.152 181.912\",\n      \"0.789 0.154 211.53\",\n      \"0.707 0.165 254.624\",\n      \"0.673 0.182 276.935\",\n      \"0.702 0.183 293.541\",\n      \"0.718 0.202 349.761\",\n    ],\n  ],\n  [\n    500,\n    [\n      \"0.556 0 0\",\n      \"0.637 0.237 25.331\",\n      \"0.705 0.213 47.604\",\n      \"0.769 0.188 70.08\",\n      \"0.795 0.184 86.047\",\n      \"0.723 0.219 149.579\",\n      \"0.704 0.14 182.503\",\n      \"0.715 0.143 215.221\",\n      \"0.623 0.214 259.815\",\n      \"0.585 0.233 277.117\",\n      \"0.606 0.25 292.717\",\n      \"0.656 0.241 354.308\",\n    ],\n  ],\n  [\n    600,\n    [\n      \"0.439 0 0\",\n      \"0.577 0.245 27.325\",\n      \"0.646 0.222 41.116\",\n      \"0.666 0.179 58.318\",\n      \"0.681 0.162 75.834\",\n      \"0.627 0.194 149.214\",\n      \"0.6 0.118 184.704\",\n      \"0.609 0.126 221.723\",\n      \"0.546 0.245 262.881\",\n      \"0.511 0.262 276.966\",\n      \"0.541 0.281 293.009\",\n      \"0.592 0.249 0.584\",\n    ],\n  ],\n  [\n    800,\n    [\n      \"0.269 0 0\",\n      \"0.444 0.177 26.899\",\n      \"0.47 0.157 37.304\",\n      \"0.473 0.137 46.201\",\n      \"0.476 0.114 61.907\",\n      \"0.448 0.119 151.328\",\n      \"0.437 0.078 188.216\",\n      \"0.45 0.085 224.283\",\n      \"0.424 0.199 265.638\",\n      \"0.398 0.195 277.366\",\n      \"0.432 0.232 292.759\",\n      \"0.459 0.187 3.815\",\n    ],\n  ],\n];\n\n/** Tailwind v4's palette (MIT): 12 hues × shades 200/400/500/600/800, a row per shade. */\nconst DEFAULT_SWATCHES: Swatch[] = TAILWIND_SHADES.flatMap(([shade, values]) =>\n  values.map((value, i) => ({ color: `oklch(${value})`, name: `${TAILWIND_HUES[i]}-${shade}` })),\n);\n\nconst swatchColor = (swatch: Swatch) => (typeof swatch === \"string\" ? swatch : swatch.color);\nconst swatchName = (swatch: Swatch) => (typeof swatch === \"string\" ? swatch : swatch.name);\n\nfunction SwatchGrid({\n  swatches,\n  columns,\n  value,\n  contrastWith,\n  onSelect,\n}: {\n  swatches: Swatch[];\n  columns: number;\n  /** Current color, normalized with formatOklchChannels. */\n  value: string;\n  contrastWith?: string;\n  onSelect: (color: string) => void;\n}) {\n  // Swatches are compared as the picker emits them (oklch, clamped to sRGB).\n  const items = useMemo(() => {\n    const seen = new Set<string>();\n    return swatches.flatMap((swatch) => {\n      const channels = toOklchChannels(swatchColor(swatch));\n      if (!channels) return [];\n      const id = formatOklchChannels({ ...channels, alpha: 1 });\n      if (seen.has(id)) return [];\n      seen.add(id);\n      return [{ id, name: swatchName(swatch), paint: paint(channels) }];\n    });\n  }, [swatches]);\n  const selected = toOklchChannels(value);\n  const selectedId = selected && formatOklchChannels({ ...selected, alpha: 1 });\n\n  return (\n    <ListBox\n      data-slot=\"color-picker-swatches\"\n      aria-label=\"Preset colors\"\n      layout=\"grid\"\n      selectionMode=\"single\"\n      disallowEmptySelection\n      selectedKeys={selectedId ? [selectedId] : []}\n      onSelectionChange={(keys) => {\n        const [key] = keys === \"all\" ? [] : [...keys];\n        if (typeof key === \"string\") onSelect(key);\n      }}\n      className=\"grid gap-1 outline-none\"\n      style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}\n    >\n      {items.map((item) => {\n        const ratio = contrastWith ? contrastRatio(contrastWith, item.id) : undefined;\n        const fails = ratio !== undefined && ratio < CONTRAST_AA;\n        return (\n          <ListBoxItem\n            key={item.id}\n            id={item.id}\n            textValue={item.name}\n            aria-label={ratio === undefined ? item.name : `${item.name}, ${ratio.toFixed(2)}:1`}\n            data-failing={fails || undefined}\n            className=\"relative aspect-square cursor-pointer overflow-hidden rounded-sm ring-1 ring-foreground/10 outline-none ring-inset data-focus-visible:ring-2 data-focus-visible:ring-ring data-selected:ring-2 data-selected:ring-foreground\"\n            style={{ background: item.paint }}\n          >\n            {fails && <span aria-hidden className={cn(\"absolute inset-0\", HATCH)} />}\n          </ListBoxItem>\n        );\n      })}\n    </ListBox>\n  );\n}\n\nconst parseOr = (value: string, fallback: OklchChannels) => toOklchChannels(value) ?? fallback;\nconst BLACK: OklchChannels = { l: 0, c: 0, h: 0, alpha: 1 };\n\nfunction ChannelSlider({\n  channel,\n  channels,\n  onChange,\n  failing = [],\n  marks = [],\n}: {\n  channel: Channel;\n  channels: OklchChannels;\n  onChange: (value: number) => void;\n  /** Value ranges drawn hatched on the track (fail the contrast target). */\n  failing?: Array<[number, number]>;\n  /** Values marked with a tick on the track (contrast thresholds). */\n  marks?: number[];\n}) {\n  const { label, min, max, step, format } = CHANNELS[channel];\n  const percent = (value: number) => `${((value - min) / (max - min)) * 100}%`;\n  return (\n    <Slider\n      data-slot=\"color-picker-slider\"\n      data-channel={channel}\n      className=\"flex flex-col gap-1.5\"\n      minValue={min}\n      maxValue={max}\n      step={step}\n      value={channels[channel]}\n      onChange={onChange}\n    >\n      <div className=\"flex items-center justify-between text-xs\">\n        <Label className=\"text-muted-foreground\">{label}</Label>\n        <SliderOutput className=\"font-mono tabular-nums\">\n          {({ state }) => format(state.getThumbValue(0))}\n        </SliderOutput>\n      </div>\n      <SliderTrack\n        className=\"relative h-4 w-full rounded-md ring-1 ring-foreground/10 ring-inset\"\n        style={{ background: trackGradient(channels, channel) }}\n      >\n        {failing.map(([from, to]) => (\n          <span\n            key={`${from}-${to}`}\n            aria-hidden\n            data-slot=\"color-picker-failing\"\n            className={cn(\"pointer-events-none absolute inset-y-0\", HATCH)}\n            style={{ left: percent(from), width: `calc(${percent(to)} - ${percent(from)})` }}\n          />\n        ))}\n        {marks.map((mark) => (\n          <span\n            key={mark}\n            aria-hidden\n            className=\"pointer-events-none absolute -inset-y-0.5 w-px -translate-x-1/2 bg-foreground\"\n            style={{ left: percent(mark) }}\n          />\n        ))}\n        <SliderThumb\n          className=\"top-1/2 size-4 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4),0_1px_2px_rgb(0_0_0/0.3)] outline-none data-focus-visible:ring-3 data-focus-visible:ring-ring/50\"\n          style={{ background: paint(channels) }}\n        />\n      </SliderTrack>\n    </Slider>\n  );\n}\n\n/** Lightness values where the contrast crosses `ratio` (the edges of its passing ranges). */\nfunction boundaries(ranges: Array<[number, number]>): number[] {\n  return ranges.flat().filter((value) => value > 0 && value < 1);\n}\n\n/** Lightness ranges that don't pass: the complement of `passing` in 0–1. */\nfunction complement(passing: Array<[number, number]>): Array<[number, number]> {\n  const gaps: Array<[number, number]> = [];\n  let cursor = 0;\n  for (const [from, to] of passing) {\n    if (from > cursor) gaps.push([cursor, from]);\n    cursor = to;\n  }\n  if (cursor < 1) gaps.push([cursor, 1]);\n  return gaps;\n}\n\ntype ColorPickerPanelProps = {\n  /** Any CSS color. */\n  value: string;\n  /** Called with an `oklch(…)` string, chroma clamped to sRGB; alpha is kept. */\n  onChange: (value: string) => void;\n  /**\n   * The background this color sits on. Shows the contrast ratio, hatches the\n   * lightness that fails AA, and offers buttons that move to the nearest\n   * lightness reaching AA / AAA while keeping hue and chroma.\n   */\n  contrastWith?: string;\n  /**\n   * Preset colors shown under the sliders (Tailwind's palette by default; `[]`\n   * hides them). With `contrastWith`, the ones failing AA are hatched.\n   */\n  swatches?: Swatch[];\n  /** Columns of the swatch grid. */\n  swatchColumns?: number;\n  className?: string;\n};\n\nfunction ColorPickerPanel({\n  value,\n  onChange,\n  contrastWith,\n  swatches = DEFAULT_SWATCHES,\n  swatchColumns = TAILWIND_HUES.length,\n  className,\n}: ColorPickerPanelProps) {\n  // Channels are kept locally so hue survives dragging chroma to 0 (grays have no hue).\n  const [channels, setChannels] = useState(() => parseOr(value, BLACK));\n  useEffect(() => {\n    setChannels((current) =>\n      formatOklchChannels(current) === formatOklchChannels(parseOr(value, current))\n        ? current\n        : parseOr(value, current),\n    );\n  }, [value]);\n\n  const update = (next: OklchChannels) => {\n    setChannels(next);\n    onChange(formatOklchChannels(next));\n  };\n\n  const current = formatOklchChannels(channels);\n  const { l, c, h } = channels;\n  const contrast = useMemo(() => {\n    if (!contrastWith) return undefined;\n    const color = `oklch(${l} ${c} ${h})`;\n    const aa = passingLightnessRanges(color, contrastWith, CONTRAST_AA);\n    const aaa = passingLightnessRanges(color, contrastWith, CONTRAST_AAA);\n    return {\n      failing: complement(aa),\n      marks: [...boundaries(aa), ...boundaries(aaa)],\n      fixAA: nearestContrastColor(color, contrastWith, CONTRAST_AA),\n      fixAAA: nearestContrastColor(color, contrastWith, CONTRAST_AAA),\n    };\n  }, [l, c, h, contrastWith]);\n\n  const ratio = contrastWith ? contrastRatio(contrastWith, current) : undefined;\n  const level = ratio === undefined ? undefined : contrastLevel(ratio);\n  const fix = (target: string | undefined) => {\n    const next = target && toOklchChannels(target);\n    if (next) update({ ...next, h: channels.h, alpha: channels.alpha });\n  };\n\n  return (\n    <div data-slot=\"color-picker-panel\" className={cn(\"flex flex-col gap-3\", className)}>\n      <div className=\"flex items-center gap-2\">\n        <div\n          data-slot=\"color-picker-preview\"\n          className=\"flex h-10 flex-1 items-center justify-center rounded-md text-sm font-medium ring-1 ring-foreground/10\"\n          style={{ background: contrastWith ?? current, color: contrastWith ? current : undefined }}\n        >\n          {contrastWith && \"Aa\"}\n        </div>\n        {ratio !== undefined && (\n          <div\n            data-slot=\"color-picker-ratio\"\n            data-level={level}\n            className={cn(\n              \"flex w-20 flex-col items-end text-xs\",\n              level === \"Fail\" && \"text-destructive\",\n            )}\n          >\n            <span className=\"font-mono text-sm tabular-nums\">{ratio.toFixed(2)}:1</span>\n            <span className=\"text-muted-foreground\">{level === \"AA18\" ? \"AA large\" : level}</span>\n          </div>\n        )}\n      </div>\n      <ChannelSlider\n        channel=\"l\"\n        channels={channels}\n        onChange={(l) => update({ ...channels, l })}\n        failing={contrast?.failing}\n        marks={contrast?.marks}\n      />\n      <ChannelSlider channel=\"c\" channels={channels} onChange={(c) => update({ ...channels, c })} />\n      <ChannelSlider channel=\"h\" channels={channels} onChange={(h) => update({ ...channels, h })} />\n      {swatches.length > 0 && (\n        <SwatchGrid\n          swatches={swatches}\n          columns={swatchColumns}\n          value={current}\n          contrastWith={contrastWith}\n          onSelect={(color) => {\n            const next = toOklchChannels(color);\n            if (next) update({ ...next, alpha: channels.alpha });\n          }}\n        />\n      )}\n      {contrast && (\n        <div className=\"flex gap-2\">\n          {(\n            [\n              [\"AA\", contrast.fixAA, CONTRAST_AA],\n              [\"AAA\", contrast.fixAAA, CONTRAST_AAA],\n            ] as const\n          ).map(([name, target, min]) => (\n            <Button\n              key={name}\n              size=\"sm\"\n              variant=\"outline\"\n              className=\"flex-1\"\n              isDisabled={!target || (ratio ?? 0) >= min}\n              onPress={() => fix(target)}\n            >\n              <ContrastIcon />\n              Fix to {name}\n            </Button>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\ntype ColorPickerProps = ColorPickerPanelProps & {\n  /** Accessible name of the swatch button and the dialog. */\n  label?: string;\n  isDisabled?: boolean;\n};\n\n/** A swatch button that opens a ColorPickerPanel in a popover. */\nfunction ColorPicker({\n  value,\n  onChange,\n  contrastWith,\n  swatches,\n  swatchColumns,\n  label = \"Pick color\",\n  isDisabled,\n  className,\n}: ColorPickerProps) {\n  return (\n    <DialogTrigger>\n      <RACButton\n        data-slot=\"color-picker-trigger\"\n        aria-label={label}\n        isDisabled={isDisabled}\n        className={cn(\n          \"size-8 shrink-0 cursor-pointer rounded-md ring-1 ring-foreground/15 outline-none data-disabled:cursor-not-allowed data-disabled:opacity-50 data-focus-visible:ring-3 data-focus-visible:ring-ring/50\",\n          className,\n        )}\n        style={{ background: value }}\n      />\n      <Popover placement=\"bottom start\" className=\"w-72 overflow-y-auto\">\n        <Dialog aria-label={label} className=\"outline-none\">\n          <ColorPickerPanel\n            value={value}\n            onChange={onChange}\n            contrastWith={contrastWith}\n            swatches={swatches}\n            swatchColumns={swatchColumns}\n          />\n        </Dialog>\n      </Popover>\n    </DialogTrigger>\n  );\n}\n\nexport type { ColorPickerPanelProps, ColorPickerProps, Swatch };\nexport { ColorPicker, ColorPickerPanel, DEFAULT_SWATCHES };\n",
      "type": "registry:component",
      "target": "components/fma-ui/color-picker.tsx"
    }
  ],
  "meta": {
    "status": "stable",
    "source": "original",
    "tags": [
      "forms",
      "color",
      "contrast",
      "oklch",
      "react-aria"
    ]
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}