shadimage
extend
uicolor-pickersource: ok · hit

Color Picker

An OKLCH-aware popover color picker with OKLCH, HSL, RGB, and hex input formats.

npx shadcn add "https://ui.extend.ai/r/color-picker.json"
free

Interaction

tooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

buttonpopoverselecttooltiputils

Files2 · source cached 10/14/2026, 11:29:43 AM

components/ui/color-picker.tsx
"use client"

import * as React from "react"

import {
  clamp,
  cuspColorForHue,
  formatColorValue,
  formatCssColor,
  getOklchPlanePixels,
  hslToRgb,
  hsvToHsl,
  normalizeHue,
  oklchPlaneToRgb,
  oklchToRgb,
  parseColorInput,
  rgbaToHex,
  rgbToHsv,
  rgbToOklch,
  rgbToOklchPlane,
  type ColorFormat,
  type ColorParts,
  type HslColor,
  type HsvColor,
  type OklchColor,
  type RgbColor,
} from "@/lib/color-picker-utils"
import type { RegistryIconProps } from "@/lib/registry-icon-props"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
} from "@/components/ui/select"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { IconPlaceholder } from "@/components/icon-placeholder"

const cossInputKeyboardFocusRing =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
const cossInputWrapperFocusRing =
  "focus-within:border-input-focus focus-within:ring-1 focus-within:ring-input-focus/24 focus-within:ring-offset-0 dark:focus-within:border-input-focus dark:focus-within:ring-input-focus/24"
const cossOutlineEdge =
  "before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit]"
const cossOutlineEdgeHighlight =
  "before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"

interface HsvaColor {
  h: number
  s: number
  v: number
  a: number
}

type EyeDropperConstructor = new () => {
  open: () => Promise<{ sRGBHex: string }>
}

declare global {
  interface Window {
    EyeDropper?: EyeDropperConstructor
  }
}

const COLOR_FORMAT_OPTIONS: Array<{ value: ColorFormat; label: string }> = [
  { value: "oklch", label: "OKLCH" },
  { value: "hsl", label: "HSL" },
  { value: "rgb", label: "RGB" },
  { value: "hex", label: "Hex" },
]

const OK_HUE_TRACK = `linear-gradient(to bottom, ${Array.from(
  { length: 25 },
  (_, i) => rgbaToHex(cuspColorForHue(i * 15))
).join(", ")})`

// Ignore hue noise from RGB round trips.
function withHueContinuity<T extends { h: number }>(
  next: T,
  previousHue: number,
  chromaFraction: number
): T {
  const delta = Math.abs(next.h - previousHue) % 360
  const angular = Math.min(delta, 360 - delta)
  const tolerance = Math.min(10, 1.5 / Math.max(chromaFraction, 0.15))
  return angular < tolerance ? { ...next, h: previousHue } : next
}

const triggerGroupClass =
  "relative flex h-10 w-full min-w-0 items-center rounded-md border border-input bg-popover not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-input-focus/32 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] not-has-disabled:not-has-focus-visible:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:border-input-focus has-focus-visible:ring-[3px] has-disabled:opacity-64 has-[:disabled,:focus-visible]:shadow-none dark:bg-input/32 dark:ring-input-focus/32 dark:has-focus-visible:border-input-focus dark:not-has-focus-visible:border-white/[0.08] dark:not-has-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-has-disabled:not-has-focus-visible:before:shadow-[0_-1px_--theme(--color-white/6%)]"

const fieldSurfaceClass = cn(
  "relative rounded-md border border-input bg-popover text-foreground shadow-xs/5 transition-shadow not-dark:bg-clip-padding dark:border-white/[0.08] dark:bg-input/32",
  cossOutlineEdge,
  cossOutlineEdgeHighlight,
  cossInputWrapperFocusRing
)

const channelInputClass =
  "h-full w-full min-w-0 border-0 bg-transparent px-1 py-0 text-center text-sm leading-none tabular-nums text-foreground outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus:border-0 focus:outline-none focus:ring-0"

const thumbShadow = "shadow-[0_0_0_1px_rgb(0_0_0/24%),0_1px_2px_rgb(0_0_0/32%)]"

function Checkerboard({
  className,
  size = 8,
}: {
  className?: string
  size?: number
}) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "pointer-events-none absolute inset-0 bg-white [--checker:#00000024] dark:bg-neutral-700 dark:[--checker:#ffffff2e]",
        className
      )}
      style={{
        backgroundImage:
          "conic-gradient(var(--checker) 0 25%, transparent 0 50%, var(--checker) 0 75%, transparent 0)",
        backgroundSize: `${size}px ${size}px`,
      }}
    />
  )
}

interface DraftInputProps
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "value" | "onChange"
  > {
  value: string
  onCommitText: (text: string) => void
  onStep?: (direction: 1 | -1, big: boolean) => void
  copyValue?: () => string
  inputRef?: React.MutableRefObject<HTMLInputElement | null>
}

function DraftInput({
  value,
  onCommitText,
  onStep,
  copyValue,
  inputRef,
  ...props
}: DraftInputProps) {
  const [draft, setDraft] = React.useState<string | null>(null)
  const internalRef = React.useRef<HTMLInputElement | null>(null)
  // Keep mouseup from clearing the focus selection.
  const keepSelectionRef = React.useRef(false)

  const setRefs = (node: HTMLInputElement | null) => {
    internalRef.current = node
    if (inputRef) inputRef.current = node
  }

  const commitDraft = () => {
    if (draft !== null && draft.trim() !== value) onCommitText(draft)
    setDraft(null)
  }

  const reselect = () => {
    requestAnimationFrame(() => internalRef.current?.select())
  }

  return (
    <input
      autoComplete="off"
      autoCorrect="off"
      spellCheck={false}
      type="text"
      {...props}
      ref={setRefs}
      value={draft ?? value}
      onFocus={(event) => {
        setDraft(event.currentTarget.value)
        event.currentTarget.select()
        keepSelectionRef.current = true
      }}
      onMouseUp={(event) => {
        if (keepSelectionRef.current) {
          event.preventDefault()
          keepSelectionRef.current = false
        }
      }}
      onChange={(event) => setDraft(event.target.value)}
      onBlur={commitDraft}
      onCopy={(event) => {
        if (!copyValue) return
        const input = event.currentTarget
        const fullySelected =
          input.value.length > 0 &&
          input.selectionStart === 0 &&
          input.selectionEnd === input.value.length
        if (!fullySelected) return
        event.preventDefault()
        event.clipboardData.setData("text/plain", copyValue())
      }}
      onKeyDown={(event) => {
        if (event.key === "Enter") {
          commitDraft()
          reselect()
        } else if (event.key === "Escape") {
          if (draft !== null) {
            event.stopPropagation()
            setDraft(null)
          }
        } else if (
          onStep &&
          (event.key === "ArrowUp" || event.key === "ArrowDown")
        ) {
          event.preventDefault()
          setDraft(null)
          onStep(event.key === "ArrowUp" ? 1 : -1, event.shiftKey)
          reselect()
        }
      }}
    />
  )
}

interface ChannelSpec {
  key: string
  label: string
  ariaLabel: string
  value: string
  numericValue: number
  step: number
  bigStep: number
  commit: (next: number) => void
}

function parseChannelText(text: string): number | null {
  const numeric = Number.parseFloat(text.replace(/[%°]/g, "").trim())
  return Number.isFinite(numeric) ? numeric : null
}

function ChannelGroup({
  channels,
  copyValue,
  disabled,
}: {
  channels: ChannelSpec[]
  copyValue: string
  disabled?: boolean
}) {
  return (
    <div className="flex items-start gap-1.5">
      <div className="min-w-0 flex-1">
        <div className={cn(fieldSurfaceClass, "grid h-8 grid-cols-3")}>
          {channels.map((channel) => (
            <DraftInput
              key={channel.key}
              aria-label={channel.ariaLabel}
              className={channelInputClass}
              disabled={disabled}
              inputMode="decimal"
              value={channel.value}
              onCommitText={(text) => {
                const numeric = parseChannelText(text)
                if (numeric !== null) channel.commit(numeric)
              }}
              onStep={(direction, big) =>
                channel.commit(
                  channel.numericValue +
                    direction * (big ? channel.bigStep : channel.step)
                )
              }
            />
          ))}
          <span
            aria-hidden="true"
            className="pointer-events-none absolute inset-y-0 left-1/3 w-px bg-input dark:bg-white/[0.08]"
          />
          <span
            aria-hidden="true"
            className="pointer-events-none absolute inset-y-0 left-2/3 w-px bg-input dark:bg-white/[0.08]"
          />
        </div>
        <div className="mt-1 grid h-4 grid-cols-3 items-center text-center text-xs font-medium text-muted-foreground">
          {channels.map((channel) => (
            <span key={channel.key}>{channel.label}</span>
          ))}
        </div>
      </div>
      <CopyColorButton text={copyValue} />
    </div>
  )
}

function SaturationValueArea({
  hue,
  s,
  v,
  color,
  onMove,
}: {
  hue: number
  s: number
  v: number
  color: string
  onMove: (s: number, v: number) => void
}) {
  const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
  // Avoid redraws for imperceptible hue changes.
  const quantizedHue = Math.round(normalizeHue(hue) * 2) / 2

  React.useEffect(() => {
    const canvas = canvasRef.current
    const context = canvas?.getContext("2d")
    if (!canvas || !context) return
    const image = context.createImageData(canvas.width, canvas.height)
    image.data.set(
      getOklchPlanePixels(canvas.width, canvas.height, quantizedHue)
    )
    context.putImageData(image, 0, 0)
  }, [quantizedHue])

  const update = (event: React.PointerEvent<HTMLButtonElement>) => {
    const rect = event.currentTarget.getBoundingClientRect()
    const inset = 7
    onMove(
      clamp(
        ((event.clientX - rect.left - inset) / (rect.width - inset * 2)) * 100,
        0,
        100
      ),
      clamp(
        100 -
          ((event.clientY - rect.top - inset) / (rect.height - inset * 2)) *
            100,
        0,
        100
      )
    )
  }

  return (
    <button
      type="button"
      aria-label="Chroma and lightness"
      className={cn(
        "relative size-53 shrink-0 cursor-crosshair touch-none rounded-lg",
        cossInputKeyboardFocusRing
      )}
      onPointerDown={(event) => {
        event.preventDefault()
        event.currentTarget.setPointerCapture(event.pointerId)
        event.currentTarget.focus()
        update(event)
      }}
      onPointerMove={(event) => {
        if ((event.buttons & 1) === 0) return
        update(event)
      }}
      onKeyDown={(event) => {
        const delta = event.shiftKey ? 10 : 1
        let next: [number, number] | null = null
        if (event.key === "ArrowLeft") next = [s - delta, v]
        if (event.key === "ArrowRight") next = [s + delta, v]
        if (event.key === "ArrowUp") next = [s, v + delta]
        if (event.key === "ArrowDown") next = [s, v - delta]
        if (next) {
          event.preventDefault()
          onMove(clamp(next[0], 0, 100), clamp(next[1], 0, 100))
        }
      }}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        width={168}
        height={168}
        className="pointer-events-none absolute inset-0 size-full rounded-[inherit]"
      />
      <span
        className={cn(
          "pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] border-white",
          thumbShadow
        )}
        style={{
          left: `calc(7px + (100% - 14px) * ${clamp(s, 0, 100) / 100})`,
          top: `calc(7px + (100% - 14px) * ${clamp(100 - v, 0, 100) / 100})`,
          backgroundColor: color,
        }}
      />
    </button>
  )
}

function VerticalSlider({
  ariaLabel,
  ariaValueMax,
  ariaValueNow,
  ariaValueText,
  fraction,
  onFraction,
  onStep,
  track,
}: {
  ariaLabel: string
  ariaValueMax: number
  ariaValueNow: number
  ariaValueText?: string
  fraction: number
  onFraction: (fraction: number) => void
  onStep: (direction: 1 | -1, big: boolean) => void
  track: React.ReactNode
}) {
  const update = (event: React.PointerEvent<HTMLButtonElement>) => {
    const rect = event.currentTarget.getBoundingClientRect()
    const inset = 2.5
    onFraction(
      clamp(
        (event.clientY - rect.top - inset) / (rect.height - inset * 2),
        0,
        1
      )
    )
  }

  return (
    <button
      type="button"
      role="slider"
      aria-label={ariaLabel}
      aria-orientation="vertical"
      aria-valuemin={0}
      aria-valuemax={ariaValueMax}
      aria-valuenow={ariaValueNow}
      aria-valuetext={ariaValueText}
      className={cn(
        "relative w-3 shrink-0 touch-none self-stretch rounded-full",
        cossInputKeyboardFocusRing
      )}
      onPointerDown={(event) => {
        event.preventDefault()
        event.currentTarget.setPointerCapture(event.pointerId)
        event.currentTarget.focus()
        update(event)
      }}
      onPointerMove={(event) => {
        if ((event.buttons & 1) === 0) return
        update(event)
      }}
      onKeyDown={(event) => {
        let direction: 1 | -1 | null = null
        if (event.key === "ArrowUp" || event.key === "ArrowRight") direction = 1
        if (event.key === "ArrowDown" || event.key === "ArrowLeft")
          direction = -1
        if (direction) {
          event.preventDefault()
          onStep(direction, event.shiftKey)
        }
      }}
    >
      <span className="absolute inset-0 overflow-hidden rounded-full ring-1 ring-black/8 ring-inset dark:ring-white/12">
        {track}
      </span>
      <span
        className="pointer-events-none absolute left-1/2 h-[5px] w-[calc(100%+3px)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/20%),0_1px_2px_rgb(0_0_0/24%)]"
        style={{ top: `calc(2.5px + (100% - 5px) * ${clamp(fraction, 0, 1)})` }}
      />
    </button>
  )
}

function CopyColorButton({
  text,
  label = "Copy color",
}: {
  text: string
  label?: string
}) {
  const [copied, setCopied] = React.useState(false)

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(text)
      setCopied(true)
      window.setTimeout(() => setCopied(false), 1200)
    } catch {
      // Clipboard access can be unavailable outside secure browser contexts.
    }
  }

  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            type="button"
            variant="outline"
            size="icon-sm"
            aria-label={label}
            className="size-8! shrink-0"
            onClick={handleCopy}
          />
        }
      >
        {copied ? (
          <IconPlaceholder
            lucide="Check"
            tabler="IconCheck"
            hugeicons="Tick02Icon"
            phosphor="CheckIcon"
            remixicon="RiCheckLine"
            className="size-4"
          />
        ) : (
          <IconPlaceholder
            lucide="Copy"
            tabler="IconCopy"
            hugeicons="Copy01Icon"
            phosphor="CopyIcon"
            remixicon="RiFileCopyLine"
            className="size-4"
          />
        )}
      </TooltipTrigger>
      <TooltipContent side="top">{copied ? "Copied" : "Copy"}</TooltipContent>
    </Tooltip>
  )
}

function AlphaScrubField({
  alphaPercent,
  disabled,
  onCommit,
  scrubbable = true,
  className,
  inputClassName,
  suffixClassName,
}: {
  alphaPercent: number
  disabled?: boolean
  onCommit: (percent: number) => void
  scrubbable?: boolean
  className?: string
  inputClassName?: string
  suffixClassName?: string
}) {
  const inputRef = React.useRef<HTMLInputElement | null>(null)
  const scrubRef = React.useRef<{
    startX: number
    startPercent: number
    scrubbed: boolean
  } | null>(null)
  const rounded = Math.round(alphaPercent)

  const scrubHandlers: React.HTMLAttributes<HTMLDivElement> = scrubbable
    ? {
        onPointerDown: (event) => {
          if (disabled) return
          if (document.activeElement === inputRef.current) return
          event.preventDefault()
          event.currentTarget.setPointerCapture(event.pointerId)
          scrubRef.current = {
            startX: event.clientX,
            startPercent: alphaPercent,
            scrubbed: false,
          }
        },
        onPointerMove: (event) => {
          const scrub = scrubRef.current
          if (!scrub || (event.buttons & 1) === 0) return
          const dx = event.clientX - scrub.startX
          if (!scrub.scrubbed && Math.abs(dx) < 3) return
          scrub.scrubbed = true
          onCommit(clamp(scrub.startPercent + dx / 2, 0, 100))
        },
        onPointerUp: () => {
          const scrub = scrubRef.current
          scrubRef.current = null
          if (scrub && !scrub.scrubbed) inputRef.current?.focus()
        },
        onPointerCancel: () => {
          scrubRef.current = null
        },
      }
    : {}

  return (
    <div
      className={cn(
        "flex h-full shrink-0 items-center gap-0.5",
        scrubbable && "cursor-ew-resize touch-none select-none",
        className
      )}
      {...scrubHandlers}
    >
      <DraftInput
        aria-label="Opacity"
        className={cn(
          "border-0 bg-transparent p-0 text-right tabular-nums outline-none focus:border-0 focus:ring-0",
          inputClassName ?? "w-8 text-base"
        )}
        disabled={disabled}
        inputMode="decimal"
        inputRef={inputRef}
        value={String(rounded)}
        onCommitText={(text) => {
          const numeric = parseChannelText(text)
          if (numeric !== null) onCommit(clamp(numeric, 0, 100))
        }}
        onStep={(direction, big) =>
          onCommit(clamp(alphaPercent + direction * (big ? 10 : 1), 0, 100))
        }
      />
      <span
        className={cn("text-muted-foreground", suffixClassName ?? "text-base")}
      >
        %
      </span>
    </div>
  )
}

export type ColorPickerProps = {
  value?: string
  color?: string
  onChange?: (value: string) => void
  label?: string
  disabled?: boolean
  showAlpha?: boolean
  defaultFormat?: ColorFormat
  variant?: "input" | "swatch" | "panel"
  className?: string
  icon?: React.ComponentType<RegistryIconProps>
  rainbowTrigger?: boolean
  onTriggerMouseDown?: React.MouseEventHandler<HTMLButtonElement>
  onTriggerPointerDown?: React.PointerEventHandler<HTMLButtonElement>
}

export function ColorPicker({
  value: valueProp,
  color,
  onChange,
  label = "Choose color",
  disabled = false,
  showAlpha = false,
  defaultFormat = "hex",
  variant,
  className,
  icon: IconComponent,
  rainbowTrigger = false,
  onTriggerMouseDown,
  onTriggerPointerDown,
}: ColorPickerProps) {
  const value = valueProp ?? color ?? "#111827"
  const resolvedVariant = variant ?? (color !== undefined ? "swatch" : "input")
  const [open, setOpen] = React.useState(false)

  // Keep drag coordinates independent from exact typed RGB values.
  const [picker, setPicker] = React.useState<{
    plane: HsvColor
    rgb: RgbColor
    a: number
  }>(() => {
    const parsed = parseColorInput(value, "hex")
    if (!parsed)
      return { plane: { h: 0, s: 0, v: 0 }, rgb: { r: 0, g: 0, b: 0 }, a: 1 }
    return {
      plane: rgbToOklchPlane(parsed.rgb),
      rgb: parsed.rgb,
      a: showAlpha ? (parsed.alpha ?? 1) : 1,
    }
  })
  const [format, setFormat] = React.useState<ColorFormat>(defaultFormat)
  const [controlledValue, setControlledValue] = React.useState({
    value,
    showAlpha,
    pending: [] as string[],
  })
  const triggerGroupRef = React.useRef<HTMLDivElement>(null)

  if (
    controlledValue.value !== value ||
    controlledValue.showAlpha !== showAlpha
  ) {
    const parsed = value ? parseColorInput(value, "hex") : null
    const alpha = showAlpha ? (parsed?.alpha ?? picker.a) : 1
    const incoming = parsed ? rgbaToHex(parsed.rgb, alpha) : null
    const echoIndex =
      incoming === null ? -1 : controlledValue.pending.lastIndexOf(incoming)
    setControlledValue({
      value,
      showAlpha,
      pending:
        echoIndex < 0 ? [] : controlledValue.pending.slice(echoIndex + 1),
    })
    if (
      parsed &&
      (echoIndex < 0 ||
        echoIndex === controlledValue.pending.length - 1 ||
        controlledValue.showAlpha !== showAlpha)
    ) {
      if (rgbaToHex(picker.rgb, picker.a) !== incoming) {
        const derived = rgbToOklchPlane(parsed.rgb, picker.plane.h)
        setPicker({
          plane: withHueContinuity(derived, picker.plane.h, derived.s / 100),
          rgb: parsed.rgb,
          a: alpha,
        })
      }
    }
  }

  const applyState = (next: { plane: HsvColor; rgb: RgbColor; a: number }) => {
    setPicker(next)
    const hex = rgbaToHex(next.rgb, next.a)
    if ((valueProp !== undefined || color !== undefined) && onChange) {
      setControlledValue((current) => ({
        ...current,
        pending: [...current.pending, hex],
      }))
    }
    onChange?.(hex)
  }

  const commitHsva = (next: HsvaColor) => {
    const plane: HsvColor = {
      h: clamp(next.h, 0, 360),
      s: clamp(next.s, 0, 100),
      v: clamp(next.v, 0, 100),
    }
    applyState({
      plane,
      rgb: oklchPlaneToRgb(plane),
      a: showAlpha ? clamp(next.a, 0, 1) : 1,
    })
  }

  const hsva: HsvaColor = { ...picker.plane, a: picker.a }
  const rgb = picker.rgb
  // Preserve hue for achromatic colors.
  const [srgbHue, setSrgbHue] = React.useState(0)
  const displayHsv = rgbToHsv(rgb, srgbHue)
  if (srgbHue !== displayHsv.h) setSrgbHue(displayHsv.h)
  const hsl = hsvToHsl(displayHsv)
  const rawOklch = rgbToOklch(rgb, hsva.h)
  const oklch = withHueContinuity(rawOklch, hsva.h, rawOklch.c / 0.33)
  const parts: ColorParts = { rgb, hsl, oklch, alpha: hsva.a }
  const opaqueColor = `rgb(${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)})`
  const currentColor = `rgb(${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)} / ${hsva.a})`

  const commitRgb = (
    nextRgb: RgbColor,
    alpha?: number | null,
    intendedHue?: number
  ) => {
    const exact: RgbColor = {
      r: clamp(nextRgb.r, 0, 255),
      g: clamp(nextRgb.g, 0, 255),
      b: clamp(nextRgb.b, 0, 255),
    }
    const targetHue = intendedHue ?? picker.plane.h
    const derived = rgbToOklchPlane(exact, targetHue)
    applyState({
      plane: withHueContinuity(derived, targetHue, derived.s / 100),
      rgb: exact,
      a: showAlpha ? clamp(alpha ?? picker.a, 0, 1) : 1,
    })
  }
  const commitHsl = (nextHsl: HslColor) => commitRgb(hslToRgb(nextHsl))
  const commitOklch = (nextOklch: OklchColor) =>
    commitRgb(oklchToRgb(nextOklch), undefined, normalizeHue(nextOklch.h))
  const commitText = (text: string) => {
    const parsed = parseColorInput(text, format)
    if (!parsed) return
    if (parsed.format && parsed.format !== format) setFormat(parsed.format)
    commitRgb(parsed.rgb, parsed.alpha)
  }

  const cssColor = formatCssColor(format, parts, { includeAlpha: showAlpha })
  const portalContainer =
    typeof document === "undefined" ? undefined : document.body
  const supportsEyeDropper =
    typeof window !== "undefined" && window.EyeDropper !== undefined

  const handleEyeDropper = () => {
    const eyeDropperCtor = window.EyeDropper
    if (!eyeDropperCtor) return
    new eyeDropperCtor()
      .open()
      .then((result) => {
        const parsed = parseColorInput(result.sRGBHex, "hex")
        if (parsed) commitRgb(parsed.rgb)
      })
      .catch(() => {})
  }

  const oklchChannels: ChannelSpec[] = [
    {
      key: "l",
      label: "L",
      ariaLabel: "OKLCH lightness",
      value: (oklch.l * 100).toFixed(1),
      numericValue: oklch.l * 100,
      step: 1,
      bigStep: 10,
      commit: (next) => commitOklch({ ...oklch, l: clamp(next, 0, 100) / 100 }),
    },
    {
      key: "c",
      label: "C",
      ariaLabel: "OKLCH chroma",
      value: oklch.c.toFixed(3),
      numericValue: oklch.c,
      step: 0.01,
      bigStep: 0.05,
      commit: (next) => commitOklch({ ...oklch, c: Math.max(0, next) }),
    },
    {
      key: "h",
      label: "H",
      ariaLabel: "OKLCH hue",
      value: oklch.h.toFixed(1),
      numericValue: oklch.h,
      step: 1,
      bigStep: 10,
      commit: (next) => commitOklch({ ...oklch, h: normalizeHue(next) }),
    },
  ]

  const hslChannels: ChannelSpec[] = [
    {
      key: "h",
      label: "H",
      ariaLabel: "HSL hue",
      value: hsl.h.toFixed(1),
      numericValue: hsl.h,
      step: 1,
      bigStep: 10,
      commit: (next) => commitHsl({ ...hsl, h: normalizeHue(next) }),
    },
    {
      key: "s",
      label: "S",
      ariaLabel: "HSL saturation",
      value: hsl.s.toFixed(1),
      numericValue: hsl.s,
      step: 1,
      bigStep: 10,
      commit: (next) => commitHsl({ ...hsl, s: clamp(next, 0, 100) }),
    },
    {
      key: "l",
      label: "L",
      ariaLabel: "HSL lightness",
      value: hsl.l.toFixed(1),
      numericValue: hsl.l,
      step: 1,
      bigStep: 10,
      commit: (next) => commitHsl({ ...hsl, l: clamp(next, 0, 100) }),
    },
  ]

  const rgbChannels: ChannelSpec[] = (["r", "g", "b"] as const).map(
    (channel) => ({
      key: channel,
      label: channel.toUpperCase(),
      ariaLabel: { r: "Red", g: "Green", b: "Blue" }[channel],
      value: String(Math.round(rgb[channel])),
      numericValue: rgb[channel],
      step: 1,
      bigStep: 10,
      commit: (next) => commitRgb({ ...rgb, [channel]: clamp(next, 0, 255) }),
    })
  )

  const panel = (
    <div
      className={cn(
        "flex items-stretch gap-3",
        resolvedVariant === "panel" && className
      )}
    >
      <SaturationValueArea
        hue={hsva.h}
        s={hsva.s}
        v={hsva.v}
        color={opaqueColor}
        onMove={(s, v) => commitHsva({ ...hsva, s, v })}
      />

      <div className="flex shrink-0 flex-col items-center gap-1.5 self-stretch">
        <div className="flex min-h-0 flex-1 items-stretch gap-2">
          {showAlpha && (
            <VerticalSlider
              ariaLabel="Opacity"
              ariaValueMax={100}
              ariaValueNow={Math.round(hsva.a * 100)}
              ariaValueText={`${Math.round(hsva.a * 100)}%`}
              fraction={1 - hsva.a}
              onFraction={(fraction) =>
                commitHsva({ ...hsva, a: 1 - fraction })
              }
              onStep={(direction, big) =>
                commitHsva({
                  ...hsva,
                  a: clamp(hsva.a + direction * (big ? 0.1 : 0.01), 0, 1),
                })
              }
              track={
                <>
                  <Checkerboard size={6} />
                  <span
                    className="absolute inset-0"
                    style={{
                      background: `linear-gradient(to bottom, ${opaqueColor}, transparent)`,
                    }}
                  />
                </>
              }
            />
          )}

          <VerticalSlider
            ariaLabel="Hue"
            ariaValueMax={360}
            ariaValueNow={Math.round(hsva.h)}
            fraction={clamp(hsva.h, 0, 360) / 360}
            onFraction={(fraction) =>
              commitHsva({ ...hsva, h: fraction * 360 })
            }
            onStep={(direction, big) =>
              commitHsva({
                ...hsva,
                h: clamp(hsva.h - direction * (big ? 10 : 1), 0, 360),
              })
            }
            track={
              <span
                className="absolute inset-0"
                style={{ background: OK_HUE_TRACK }}
              />
            }
          />
        </div>
        {supportsEyeDropper ? (
          <Tooltip>
            <TooltipTrigger
              render={
                <Button
                  type="button"
                  variant="outline"
                  size="icon-sm"
                  aria-label="Pick color from screen"
                  className="size-8! shrink-0"
                  onClick={handleEyeDropper}
                />
              }
            >
              <IconPlaceholder
                lucide="Pipette"
                tabler="IconColorPicker"
                hugeicons="ColorPickerIcon"
                phosphor="EyedropperIcon"
                remixicon="RiDropperLine"
                className="size-4"
              />
            </TooltipTrigger>
            <TooltipContent side="top" className="whitespace-nowrap">
              Pick color from screen
            </TooltipContent>
          </Tooltip>
        ) : null}
      </div>

      <div className="flex w-64 shrink-0 flex-col gap-2">
        <ChannelGroup
          channels={oklchChannels}
          copyValue={formatCssColor("oklch", parts, {
            includeAlpha: showAlpha,
          })}
          disabled={disabled}
        />
        <ChannelGroup
          channels={hslChannels}
          copyValue={formatCssColor("hsl", parts, { includeAlpha: showAlpha })}
          disabled={disabled}
        />
        <ChannelGroup
          channels={rgbChannels}
          copyValue={formatCssColor("rgb", parts, { includeAlpha: showAlpha })}
          disabled={disabled}
        />

        <div className="flex items-center gap-1.5">
          <div
            className={cn(
              fieldSurfaceClass,
              "flex h-8 min-w-0 flex-1 items-center"
            )}
          >
            <DraftInput
              aria-label="Color value"
              className="h-full min-w-0 flex-1 rounded-[inherit] border-0 bg-transparent px-2 py-0 text-center text-sm leading-none tabular-nums outline-none focus:border-0 focus:ring-0"
              copyValue={() => cssColor}
              value={formatColorValue(format, parts)}
              onCommitText={commitText}
            />
            {showAlpha && (
              <AlphaScrubField
                alphaPercent={hsva.a * 100}
                disabled={disabled}
                scrubbable={false}
                className="gap-0.5 pr-2 pl-0.5"
                inputClassName="w-8 text-sm"
                suffixClassName="text-sm"
                onCommit={(percent) =>
                  commitHsva({ ...hsva, a: percent / 100 })
                }
              />
            )}
          </div>
          <Select
            value={format}
            onValueChange={(next) => {
              if (
                COLOR_FORMAT_OPTIONS.some((option) => option.value === next)
              ) {
                setFormat(next as ColorFormat)
              }
            }}
          >
            <SelectTrigger
              aria-label="Color format"
              className="size-8! min-h-0 min-w-0 shrink-0 justify-center p-0 [&>[data-slot=select-icon]]:hidden [&>svg]:hidden"
            >
              <span className="flex items-center justify-center">
                <IconPlaceholder
                  lucide="ChevronsUpDown"
                  tabler="IconSelector"
                  hugeicons="UnfoldMoreIcon"
                  phosphor="CaretUpDownIcon"
                  remixicon="RiExpandUpDownLine"
                  className="size-3.5"
                />
              </span>
            </SelectTrigger>
            <SelectContent
              alignItemWithTrigger={false}
              align="end"
              className="min-w-28"
              portalProps={{ container: portalContainer }}
            >
              {COLOR_FORMAT_OPTIONS.map((option) => (
                <SelectItem key={option.value} value={option.value}>
                  {option.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
      </div>
    </div>
  )

  if (resolvedVariant === "panel") return panel

  return (
    <Popover open={open} onOpenChange={setOpen}>
      {resolvedVariant === "swatch" ? (
        <PopoverTrigger
          render={
            <Button
              type="button"
              variant={IconComponent || rainbowTrigger ? "ghost" : "outline"}
              size="icon-sm"
              aria-label={label}
              disabled={disabled}
              data-color-picker-trigger=""
              className={cn("relative shrink-0", className)}
              onMouseDown={onTriggerMouseDown}
              onPointerDown={onTriggerPointerDown}
            />
          }
        >
          {rainbowTrigger ? (
            <span
              aria-hidden="true"
              className="size-5 shrink-0 rounded-full bg-[conic-gradient(from_90deg,#ff3b30,#ffcc00,#34c759,#00c7be,#007aff,#af52de,#ff2d55,#ff3b30)] shadow-[0_0_0_1px_rgb(0_0_0/10%)]"
            />
          ) : IconComponent ? (
            <>
              <IconComponent className="size-4" />
              <span
                aria-hidden="true"
                className="absolute right-1 bottom-1 h-1 w-4 rounded-full border border-background"
                style={{ backgroundColor: currentColor }}
              />
            </>
          ) : (
            <span className="relative size-4 shrink-0 overflow-hidden rounded-[4px]">
              <Checkerboard size={4} />
              <span
                aria-hidden="true"
                className="absolute inset-0"
                style={{ backgroundColor: currentColor }}
              />
              <span
                aria-hidden="true"
                className="absolute inset-0 rounded-[inherit] ring-1 ring-black/12 ring-inset dark:ring-white/16"
              />
            </span>
          )}
        </PopoverTrigger>
      ) : (
        <div
          ref={triggerGroupRef}
          data-color-picker-trigger=""
          data-slot="color-picker"
          className={cn(triggerGroupClass, className)}
        >
          <PopoverTrigger
            render={
              <button
                type="button"
                aria-label={label}
                disabled={disabled}
                className={cn(
                  "relative ml-2 size-6 shrink-0 overflow-hidden rounded-[calc(var(--radius-md)-2px)]",
                  cossInputKeyboardFocusRing
                )}
              />
            }
          >
            <Checkerboard size={6} />
            <span
              aria-hidden="true"
              className="absolute inset-0"
              style={{ backgroundColor: currentColor }}
            />
            <span
              aria-hidden="true"
              className="absolute inset-0 rounded-[inherit] ring-1 ring-black/12 ring-inset dark:ring-white/16"
            />
          </PopoverTrigger>
          <DraftInput
            aria-label={`${label} value`}
            className="h-full w-full min-w-0 grow border-0 bg-transparent px-[calc(--spacing(2.5)-1px)] text-base tabular-nums outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus:border-0 focus:ring-0"
            copyValue={() => cssColor}
            disabled={disabled}
            value={formatColorValue(format, parts)}
            onCommitText={commitText}
          />
          {showAlpha && (
            <AlphaScrubField
              alphaPercent={hsva.a * 100}
              disabled={disabled}
              className="pr-[calc(--spacing(3)-1px)] pl-1"
              onCommit={(percent) => commitHsva({ ...hsva, a: percent / 100 })}
            />
          )}
        </div>
      )}
      <PopoverContent
        anchor={resolvedVariant === "swatch" ? undefined : triggerGroupRef}
        align={resolvedVariant === "swatch" ? "end" : "start"}
        sideOffset={8}
        portalProps={{ container: portalContainer }}
        className="w-auto p-0 [&>[data-slot=popover-viewport]]:p-3.5!"
      >
        {panel}
      </PopoverContent>
    </Popover>
  )
}

export function ColorPickerPanel({
  className,
  color,
  label,
  onChange,
  showAlpha = false,
}: {
  className?: string
  color: string
  label: string
  onChange: (color: string) => void
  showAlpha?: boolean
}) {
  return (
    <ColorPicker
      className={className}
      value={color}
      label={label}
      onChange={onChange}
      showAlpha={showAlpha}
      variant="panel"
    />
  )
}
lib/color-picker-utils.ts
export type ColorFormat = "oklch" | "hsl" | "rgb" | "hex"

/** 0-255 floats. */
export interface RgbColor {
  r: number
  g: number
  b: number
}

/** h in 0-360, s/l in 0-100. */
export interface HslColor {
  h: number
  s: number
  l: number
}

/** h in 0-360, s/v in 0-100. */
export interface HsvColor {
  h: number
  s: number
  v: number
}

/** L: 0-1; C: >= 0; H: 0-360. */
export interface OklchColor {
  l: number
  c: number
  h: number
}

export interface ParsedColor {
  rgb: RgbColor
  alpha: number | null
  format: ColorFormat | null
}

export interface ColorParts {
  rgb: RgbColor
  hsl: HslColor
  oklch: OklchColor
  alpha: number
}

export function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value))
}

export function normalizeHue(value: number): number {
  return ((value % 360) + 360) % 360
}

export function hsvToRgb({ h, s, v }: HsvColor): RgbColor {
  const hue = normalizeHue(h) / 60
  const sat = clamp(s, 0, 100) / 100
  const val = clamp(v, 0, 100) / 100
  const sector = Math.floor(hue) % 6
  const f = hue - Math.floor(hue)
  const p = val * (1 - sat)
  const q = val * (1 - f * sat)
  const t = val * (1 - (1 - f) * sat)
  const pick: [number, number, number] =
    sector === 0
      ? [val, t, p]
      : sector === 1
        ? [q, val, p]
        : sector === 2
          ? [p, val, t]
          : sector === 3
            ? [p, q, val]
            : sector === 4
              ? [t, p, val]
              : [val, p, q]

  return { r: pick[0] * 255, g: pick[1] * 255, b: pick[2] * 255 }
}

export function rgbToHsv(rgb: RgbColor, fallbackHue = 0): HsvColor {
  const r = clamp(rgb.r, 0, 255) / 255
  const g = clamp(rgb.g, 0, 255) / 255
  const b = clamp(rgb.b, 0, 255) / 255
  const max = Math.max(r, g, b)
  const min = Math.min(r, g, b)
  const delta = max - min
  const v = max * 100
  const s = max === 0 ? 0 : (delta / max) * 100

  if (delta === 0) return { h: normalizeHue(fallbackHue), s, v }

  let h: number
  if (max === r) h = (g - b) / delta + (g < b ? 6 : 0)
  else if (max === g) h = (b - r) / delta + 2
  else h = (r - g) / delta + 4

  return { h: normalizeHue(h * 60), s, v }
}

export function hsvToHsl({ h, s, v }: HsvColor): HslColor {
  const sat = clamp(s, 0, 100) / 100
  const val = clamp(v, 0, 100) / 100
  const l = val * (1 - sat / 2)
  const hslSat = l === 0 || l === 1 ? 0 : (val - l) / Math.min(l, 1 - l)

  return {
    h: normalizeHue(h),
    s: clamp(hslSat * 100, 0, 100),
    l: clamp(l * 100, 0, 100),
  }
}

export function hslToHsv({ h, s, l }: HslColor): HsvColor {
  const sat = clamp(s, 0, 100) / 100
  const light = clamp(l, 0, 100) / 100
  const v = light + sat * Math.min(light, 1 - light)
  const hsvSat = v === 0 ? 0 : 2 * (1 - light / v)

  return {
    h: normalizeHue(h),
    s: clamp(hsvSat * 100, 0, 100),
    v: clamp(v * 100, 0, 100),
  }
}

export function hslToRgb(hsl: HslColor): RgbColor {
  return hsvToRgb(hslToHsv(hsl))
}

function srgbToLinear(channel: number): number {
  const c = channel / 255
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}

function linearToSrgb(channel: number): number {
  const c =
    channel <= 0.0031308
      ? channel * 12.92
      : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055
  return c * 255
}

interface OklabColor {
  l: number
  a: number
  b: number
}

function rgbToOklab(rgb: RgbColor): OklabColor {
  const r = srgbToLinear(clamp(rgb.r, 0, 255))
  const g = srgbToLinear(clamp(rgb.g, 0, 255))
  const b = srgbToLinear(clamp(rgb.b, 0, 255))

  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)

  return {
    l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
    a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
    b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
  }
}

function oklchToOklab({ l, c, h }: OklchColor): OklabColor {
  const hueRad = (normalizeHue(h) * Math.PI) / 180
  return { l, a: c * Math.cos(hueRad), b: c * Math.sin(hueRad) }
}

export function rgbToOklch(rgb: RgbColor, fallbackHue = 0): OklchColor {
  const lab = rgbToOklab(rgb)
  const c = Math.sqrt(lab.a * lab.a + lab.b * lab.b)
  if (c < 1e-6)
    return { l: clamp(lab.l, 0, 1), c: 0, h: normalizeHue(fallbackHue) }

  return {
    l: clamp(lab.l, 0, 1),
    c,
    h: normalizeHue((Math.atan2(lab.b, lab.a) * 180) / Math.PI),
  }
}

function oklabToLinearSrgb({ l, a, b }: OklabColor): {
  r: number
  g: number
  b: number
} {
  const lp = l + 0.3963377774 * a + 0.2158037573 * b
  const mp = l - 0.1055613458 * a - 0.0638541728 * b
  const sp = l - 0.0894841775 * a - 1.291485548 * b

  const lc = lp * lp * lp
  const mc = mp * mp * mp
  const sc = sp * sp * sp

  return {
    r: 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
    g: -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
    b: -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc,
  }
}

function oklabToRgbRaw(lab: OklabColor): RgbColor {
  const linear = oklabToLinearSrgb(lab)
  return {
    r: linearToSrgb(linear.r),
    g: linearToSrgb(linear.g),
    b: linearToSrgb(linear.b),
  }
}

function oklchToRgbRaw(oklch: OklchColor): RgbColor {
  return oklabToRgbRaw(oklchToOklab(oklch))
}

function isInSrgbGamut(rgb: RgbColor): boolean {
  const eps = 0.001
  return (
    rgb.r >= -eps &&
    rgb.r <= 255 + eps &&
    rgb.g >= -eps &&
    rgb.g <= 255 + eps &&
    rgb.b >= -eps &&
    rgb.b <= 255 + eps
  )
}

function clampRgb(rgb: RgbColor): RgbColor {
  return {
    r: clamp(rgb.r, 0, 255),
    g: clamp(rgb.g, 0, 255),
    b: clamp(rgb.b, 0, 255),
  }
}

function deltaEok(first: OklabColor, second: OklabColor): number {
  const dl = first.l - second.l
  const da = first.a - second.a
  const db = first.b - second.b
  return Math.sqrt(dl * dl + da * da + db * db)
}

/** CSS Color 4 local-MINDE gamut mapping. */
export function oklchToRgb(oklch: OklchColor): RgbColor {
  const target: OklchColor = {
    l: clamp(oklch.l, 0, 1),
    c: Math.max(0, oklch.c),
    h: oklch.h,
  }
  if (target.l >= 1) return { r: 255, g: 255, b: 255 }
  if (target.l <= 0) return { r: 0, g: 0, b: 0 }

  const raw = oklchToRgbRaw(target)
  if (isInSrgbGamut(raw)) return clampRgb(raw)

  const jnd = 0.02
  const epsilon = 0.0001

  let clipped = clampRgb(raw)
  if (deltaEok(rgbToOklab(clipped), oklchToOklab(target)) < jnd) return clipped

  let min = 0
  let max = target.c
  let minInGamut = true

  while (max - min > epsilon) {
    const chroma = (min + max) / 2
    const candidate: OklchColor = { ...target, c: chroma }
    const candidateRaw = oklchToRgbRaw(candidate)

    if (minInGamut && isInSrgbGamut(candidateRaw)) {
      min = chroma
      continue
    }

    clipped = clampRgb(candidateRaw)
    const distance = deltaEok(rgbToOklab(clipped), oklchToOklab(candidate))
    if (distance < jnd) {
      if (jnd - distance < epsilon) return clipped
      minInGamut = false
      min = chroma
    } else {
      max = chroma
    }
  }

  return clipped
}

// OKHSV cusp math: https://bottosson.github.io/posts/colorpicker/

function computeMaxSaturation(a: number, b: number): number {
  let k0: number, k1: number, k2: number, k3: number, k4: number
  let wl: number, wm: number, ws: number

  if (-1.88170328 * a - 0.80936493 * b > 1) {
    k0 = 1.19086277
    k1 = 1.76576728
    k2 = 0.59662641
    k3 = 0.75515197
    k4 = 0.56771245
    wl = 4.0767416621
    wm = -3.3077115913
    ws = 0.2309699292
  } else if (1.81444104 * a - 1.19445276 * b > 1) {
    k0 = 0.73956515
    k1 = -0.45954404
    k2 = 0.08285427
    k3 = 0.1254107
    k4 = 0.14503204
    wl = -1.2684380046
    wm = 2.6097574011
    ws = -0.3413193965
  } else {
    k0 = 1.35733652
    k1 = -0.00915799
    k2 = -1.1513021
    k3 = -0.50559606
    k4 = 0.00692167
    wl = -0.0041960863
    wm = -0.7034186147
    ws = 1.707614701
  }

  let s = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b

  // Refine the gamut boundary.
  const kl = 0.3963377774 * a + 0.2158037573 * b
  const km = -0.1055613458 * a - 0.0638541728 * b
  const ks = -0.0894841775 * a - 1.291485548 * b
  const lp = 1 + s * kl
  const mp = 1 + s * km
  const sp = 1 + s * ks
  const l = lp * lp * lp
  const m = mp * mp * mp
  const s3 = sp * sp * sp
  const lds = 3 * kl * lp * lp
  const mds = 3 * km * mp * mp
  const sds = 3 * ks * sp * sp
  const lds2 = 6 * kl * kl * lp
  const mds2 = 6 * km * km * mp
  const sds2 = 6 * ks * ks * sp
  const f = wl * l + wm * m + ws * s3
  const f1 = wl * lds + wm * mds + ws * sds
  const f2 = wl * lds2 + wm * mds2 + ws * sds2
  s = s - (f * f1) / (f1 * f1 - 0.5 * f * f2)

  return s
}

function findCusp(a: number, b: number): { l: number; c: number } {
  const sCusp = computeMaxSaturation(a, b)
  const rgbAtMax = oklabToLinearSrgb({ l: 1, a: sCusp * a, b: sCusp * b })
  const lCusp = Math.cbrt(1 / Math.max(rgbAtMax.r, rgbAtMax.g, rgbAtMax.b))
  return { l: lCusp, c: lCusp * sCusp }
}

export function maxChromaForHue(hue: number): number {
  const hueRad = (normalizeHue(hue) * Math.PI) / 180
  return findCusp(Math.cos(hueRad), Math.sin(hueRad)).c
}

export function cuspColorForHue(hue: number): RgbColor {
  const hueRad = (normalizeHue(hue) * Math.PI) / 180
  const aUnit = Math.cos(hueRad)
  const bUnit = Math.sin(hueRad)
  const cusp = findCusp(aUnit, bUnit)
  return clampRgb(
    oklabToRgbRaw({ l: cusp.l, a: cusp.c * aUnit, b: cusp.c * bUnit })
  )
}

// Lift saturated dark colors above black.
const PLANE_MIN_L = 0.25

function planeLightnessFloor(chromaFraction: number): number {
  return PLANE_MIN_L * chromaFraction
}

export function oklchPlaneToRgb({ h, s, v }: HsvColor): RgbColor {
  const s01 = clamp(s, 0, 100) / 100
  const floor = planeLightnessFloor(s01)
  const lightness = floor + (clamp(v, 0, 100) / 100) * (1 - floor)
  return clampRgb(
    oklchToRgbRaw({ l: lightness, c: s01 * maxChromaForHue(h), h })
  )
}

export function rgbToOklchPlane(rgb: RgbColor, fallbackHue = 0): HsvColor {
  const oklch = rgbToOklch(rgb, fallbackHue)
  // Preserve hue below the reliable chroma threshold.
  const hue = oklch.c < 0.004 ? normalizeHue(fallbackHue) : oklch.h
  const cMax = maxChromaForHue(hue)
  const s01 = cMax > 0 ? clamp(oklch.c / cMax, 0, 1) : 0
  const floor = planeLightnessFloor(s01)
  return {
    h: hue,
    s: s01 * 100,
    v: clamp((oklch.l - floor) / (1 - floor), 0, 1) * 100,
  }
}

/** RGBA pixels for the picker plane. */
export function getOklchPlanePixels(
  width: number,
  height: number,
  hue: number
): Uint8ClampedArray {
  const data = new Uint8ClampedArray(width * height * 4)
  const hueRad = (normalizeHue(hue) * Math.PI) / 180
  const aUnit = Math.cos(hueRad)
  const bUnit = Math.sin(hueRad)
  const cMax = findCusp(aUnit, bUnit).c

  for (let x = 0; x < width; x += 1) {
    const s01 = width === 1 ? 0 : x / (width - 1)
    const chroma = s01 * cMax
    const aChroma = chroma * aUnit
    const bChroma = chroma * bUnit
    const floor = planeLightnessFloor(s01)
    for (let y = 0; y < height; y += 1) {
      const v01 = height === 1 ? 1 : 1 - y / (height - 1)
      const linear = oklabToLinearSrgb({
        l: floor + v01 * (1 - floor),
        a: aChroma,
        b: bChroma,
      })
      const i = (y * width + x) * 4
      data[i] = linearToSrgb(linear.r)
      data[i + 1] = linearToSrgb(linear.g)
      data[i + 2] = linearToSrgb(linear.b)
      data[i + 3] = 255
    }
  }

  return data
}

export function formatNumber(value: number, maxDecimals: number): string {
  const fixed = value.toFixed(maxDecimals)
  if (!fixed.includes(".")) return fixed
  const trimmed = fixed.replace(/0+$/, "").replace(/\.$/, "")
  return trimmed === "-0" ? "0" : trimmed
}

function toHexPair(channel: number): string {
  return clamp(Math.round(channel), 0, 255)
    .toString(16)
    .padStart(2, "0")
    .toUpperCase()
}

export function rgbaToHex(rgb: RgbColor, alpha = 1): string {
  const base = `#${toHexPair(rgb.r)}${toHexPair(rgb.g)}${toHexPair(rgb.b)}`
  return alpha >= 1 ? base : `${base}${toHexPair(alpha * 255)}`
}

export function formatColorValue(
  format: ColorFormat,
  parts: ColorParts
): string {
  const { rgb, hsl, oklch } = parts
  switch (format) {
    case "hex":
      return rgbaToHex(rgb).slice(1)
    case "rgb":
      return `${Math.round(clamp(rgb.r, 0, 255))} ${Math.round(clamp(rgb.g, 0, 255))} ${Math.round(
        clamp(rgb.b, 0, 255)
      )}`
    case "hsl":
      return `${formatNumber(hsl.h, 1)}° ${formatNumber(hsl.s, 1)}% ${formatNumber(hsl.l, 1)}%`
    case "oklch":
      return `${formatNumber(oklch.l * 100, 1)}% ${formatNumber(oklch.c, 3)} ${formatNumber(oklch.h, 1)}`
  }
}

export function formatCssColor(
  format: ColorFormat,
  parts: ColorParts,
  options?: { includeAlpha?: boolean }
): string {
  const includeAlpha = (options?.includeAlpha ?? true) && parts.alpha < 1
  const alphaNumber = formatNumber(clamp(parts.alpha, 0, 1), 3)

  switch (format) {
    case "hex":
      return rgbaToHex(parts.rgb, includeAlpha ? parts.alpha : 1)
    case "rgb": {
      const channels = `${Math.round(clamp(parts.rgb.r, 0, 255))}, ${Math.round(
        clamp(parts.rgb.g, 0, 255)
      )}, ${Math.round(clamp(parts.rgb.b, 0, 255))}`
      return includeAlpha
        ? `rgba(${channels}, ${alphaNumber})`
        : `rgb(${channels})`
    }
    case "hsl": {
      const channels = `${formatNumber(parts.hsl.h, 2)}, ${formatNumber(parts.hsl.s, 2)}%, ${formatNumber(
        parts.hsl.l,
        2
      )}%`
      return includeAlpha
        ? `hsla(${channels}, ${alphaNumber})`
        : `hsl(${channels})`
    }
    case "oklch": {
      const channels = `${formatNumber(parts.oklch.l * 100, 2)}% ${formatNumber(parts.oklch.c, 4)} ${formatNumber(
        parts.oklch.h,
        2
      )}`
      return includeAlpha
        ? `oklch(${channels} / ${alphaNumber})`
        : `oklch(${channels})`
    }
  }
}

interface NumericToken {
  value: number
  percent: boolean
}

function parseNumericToken(token: string): NumericToken | null {
  const match = /^([+-]?\d*\.?\d+)(%|deg|°)?$/i.exec(token.trim())
  if (!match) return null
  const value = Number.parseFloat(match[1] ?? "")
  if (!Number.isFinite(value)) return null
  return { value, percent: match[2] === "%" }
}

function parseAlphaToken(token: string): number | null {
  const parsed = parseNumericToken(token)
  if (!parsed) return null
  const value =
    parsed.percent || parsed.value > 1 ? parsed.value / 100 : parsed.value
  return clamp(value, 0, 1)
}

function rgbFromTokens(
  tokens: NumericToken[],
  allowUnitScale: boolean
): RgbColor | null {
  if (tokens.length !== 3) return null
  const scaleUnit =
    allowUnitScale &&
    tokens.every(
      (token) => !token.percent && token.value >= 0 && token.value <= 1
    )
  const channels = tokens.map((token) => {
    if (token.percent) return (token.value / 100) * 255
    return scaleUnit ? token.value * 255 : token.value
  })

  return clampRgb({
    r: channels[0] ?? 0,
    g: channels[1] ?? 0,
    b: channels[2] ?? 0,
  })
}

function hslFromTokens(tokens: NumericToken[]): RgbColor | null {
  if (tokens.length !== 3) return null
  return hslToRgb({
    h: normalizeHue(tokens[0]?.value ?? 0),
    s: clamp(tokens[1]?.value ?? 0, 0, 100),
    l: clamp(tokens[2]?.value ?? 0, 0, 100),
  })
}

function oklchFromTokens(tokens: NumericToken[]): RgbColor | null {
  if (tokens.length !== 3) return null
  const [lToken, cToken, hToken] = tokens
  if (!lToken || !cToken || !hToken) return null
  const l =
    lToken.percent || lToken.value > 1 ? lToken.value / 100 : lToken.value
  const c = cToken.percent ? (cToken.value / 100) * 0.4 : cToken.value

  return oklchToRgb({
    l: clamp(l, 0, 1),
    c: Math.max(0, c),
    h: normalizeHue(hToken.value),
  })
}

function splitAlpha(
  body: string
): { main: string; alpha: number | null } | null {
  const slashParts = body.split("/")
  if (slashParts.length > 2) return null
  if (slashParts.length === 2) {
    const alpha = parseAlphaToken(slashParts[1] ?? "")
    if (alpha === null) return null
    return { main: slashParts[0] ?? "", alpha }
  }
  return { main: body, alpha: null }
}

function tokenize(main: string): NumericToken[] | null {
  const rawTokens = main
    .split(/[\s,]+/)
    .map((token) => token.trim())
    .filter(Boolean)
  const tokens: NumericToken[] = []
  for (const raw of rawTokens) {
    const parsed = parseNumericToken(raw)
    if (!parsed) return null
    tokens.push(parsed)
  }
  return tokens
}

function parseHexBody(raw: string): ParsedColor | null {
  const body = raw.startsWith("#") ? raw.slice(1) : raw
  if (!/^[0-9a-f]+$/i.test(body)) return null

  let expanded: string
  if (body.length === 3 || body.length === 4) {
    expanded = body
      .split("")
      .map((char) => char + char)
      .join("")
  } else if (body.length === 6 || body.length === 8) {
    expanded = body
  } else {
    return null
  }

  const channels: number[] = []
  for (let i = 0; i < expanded.length; i += 2) {
    channels.push(Number.parseInt(expanded.slice(i, i + 2), 16))
  }

  return {
    rgb: { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 },
    alpha: channels.length === 4 ? (channels[3] ?? 255) / 255 : null,
    format: "hex",
  }
}

function parseFunctional(name: string, body: string): ParsedColor | null {
  const withAlpha = splitAlpha(body)
  if (!withAlpha) return null
  const tokens = tokenize(withAlpha.main)
  if (!tokens) return null

  let alpha = withAlpha.alpha
  let componentTokens = tokens
  if (tokens.length === 4 && alpha === null) {
    componentTokens = tokens.slice(0, 3)
    const last = tokens[3]
    if (!last) return null
    alpha = clamp(last.percent ? last.value / 100 : last.value, 0, 1)
  }

  let rgb: RgbColor | null = null
  let format: ColorFormat | null = null
  if (name === "rgb" || name === "rgba") {
    rgb = rgbFromTokens(componentTokens, false)
    format = "rgb"
  } else if (name === "hsl" || name === "hsla") {
    rgb = hslFromTokens(componentTokens)
    format = "hsl"
  } else if (name === "oklch") {
    rgb = oklchFromTokens(componentTokens)
    format = "oklch"
  }

  return rgb ? { rgb, alpha, format } : null
}

let cssParserContext: CanvasRenderingContext2D | null | undefined

function parseWithBrowser(raw: string): ParsedColor | null {
  if (typeof document === "undefined") return null
  if (cssParserContext === undefined) {
    try {
      cssParserContext = document.createElement("canvas").getContext("2d")
    } catch {
      cssParserContext = null
    }
  }
  if (!cssParserContext) return null

  const sentinels = ["#010203", "#030201"]
  let normalized: string | null = null
  for (const sentinel of sentinels) {
    cssParserContext.fillStyle = sentinel
    cssParserContext.fillStyle = raw
    const result = cssParserContext.fillStyle
    if (typeof result !== "string" || result === sentinel) return null
    normalized = result
  }
  if (!normalized) return null

  // Browser fallback should not change the selected format.
  if (normalized.startsWith("#")) {
    const hex = parseHexBody(normalized)
    return hex ? { ...hex, format: null } : null
  }
  const functional = /^(rgba?)\(\s*(.+?)\s*\)$/i.exec(normalized)
  if (functional) {
    const parsed = parseFunctional(
      (functional[1] ?? "").toLowerCase(),
      functional[2] ?? ""
    )
    return parsed ? { ...parsed, format: null } : null
  }
  return null
}

/** Parses supported color syntax, then falls back to the browser parser. */
export function parseColorInput(
  input: string,
  assumeFormat: ColorFormat = "hex"
): ParsedColor | null {
  const raw = input.trim()
  if (!raw) return null

  const functional = /^([a-z]+)\(\s*(.+?)\s*\)$/i.exec(raw)
  if (functional) {
    const name = (functional[1] ?? "").toLowerCase()
    if (["rgb", "rgba", "hsl", "hsla", "oklch"].includes(name)) {
      return parseFunctional(name, functional[2] ?? "")
    }
    return parseWithBrowser(raw)
  }

  const withAlpha = splitAlpha(raw)
  if (withAlpha) {
    const main = withAlpha.main.trim()

    if (/^#?[0-9a-f]{3,8}$/i.test(main)) {
      const hex = parseHexBody(main)
      if (hex)
        return {
          rgb: hex.rgb,
          alpha: withAlpha.alpha ?? hex.alpha,
          format: "hex",
        }
    }

    const tokens = tokenize(main)
    if (tokens && tokens.length === 3) {
      let rgb: RgbColor | null = null
      if (assumeFormat === "hsl") rgb = hslFromTokens(tokens)
      else if (assumeFormat === "oklch") rgb = oklchFromTokens(tokens)
      else rgb = rgbFromTokens(tokens, true)
      if (rgb) return { rgb, alpha: withAlpha.alpha, format: null }
    }
  }

  return parseWithBrowser(raw)
}

export function rgbTupleToHex(tuple: [number, number, number]): string {
  return rgbaToHex({ r: tuple[0], g: tuple[1], b: tuple[2] })
}

export function hexToRgbTuple(value: string): [number, number, number] | null {
  const parsed = parseColorInput(value, "hex")
  if (!parsed) return null
  return [
    Math.round(parsed.rgb.r),
    Math.round(parsed.rgb.g),
    Math.round(parsed.rgb.b),
  ]
}

More from extend

Browse all components in this library →