# Color Picker

> Choose a precise color, opacity, and output format.

Source: https://www.honestui.com/docs/components/color-picker

```tsx
"use client"

import { ColorPicker } from "@/components/honest-ui/ui/color-picker"

export function ColorPickerDemo() {
  return (
    <ColorPicker
      defaultValue="#3E63DD"
      className="w-full max-w-[12rem] gap-[var(--hui-space-3)]"
    >
      <ColorPicker.Area />
      <ColorPicker.Hue />
      <ColorPicker.Alpha />
      <div className="flex items-center gap-[var(--hui-space-3)]">
        <ColorPicker.Input copyable />
        <ColorPicker.Mode />
      </div>
    </ColorPicker>
  )
}

```

## Overview [#overview]

ColorPicker is a precision tool for people who need an exact color — theme builders, design settings, data-viz configuration, brand customization. It works internally in OKLCH, a perceptual color space where "a bit lighter" is the same size change everywhere on the gradient, and outputs hex, HSL, RGB, or OKLCH strings.

Use it when the person must get a specific value right. For choosing among known options (plan colors, status tints), render swatches as buttons instead — free-form picking is more power, and more surface for mistakes, than that job needs.

## Anatomy [#anatomy]

The picker composes from parts, so you can ship exactly the control each context needs:

* `ColorPicker` — the root; owns value, mode, and clamping.
* `ColorPickerArea` — the 2-D gradient field (lightness × chroma in OKLCH mode, saturation × lightness in HSL mode).
* `ColorPickerHue` — hue slider.
* `ColorPickerAlpha` — transparency slider.
* `ColorPickerInput` — text entry for the current output string.
* `ColorPickerMode` — format switcher between `hex`, `hsl`, `rgb`, and `oklch`.

## Behavior [#behavior]

**Perceptual space.** The area operates in OKLCH by default. Moving diagonally changes perceived lightness uniformly — unlike HSL fields, where dragging through yellow suddenly looks far brighter. Switch to HSL when someone needs to think in the classic saturation/lightness model.

**Gamut honesty.** OKLCH can describe colors wider-gamut displays can show but ordinary sRGB screens cannot. The component keeps your raw OKLCH value intact and only *displays* the sRGB-clamped version. What you store is what you asked for; what you see is what fits the screen. If exact on-screen reproduction matters, read back the displayed value rather than assuming identity.

**Modes.** The output string follows the active mode, and switching modes converts without drift. Alpha is included where the format supports it (`#RRGGBBAA`, `hsla()`, `rgba()`, `oklch(… / α)`).

## Keyboard interaction [#keyboard-interaction]

The area, hue, and alpha are slider primitives:

| Key                                         | Result                       |
| ------------------------------------------- | ---------------------------- |
| <kbd>ArrowLeft</kbd> / <kbd>ArrowDown</kbd> | Decrease the controlled axis |
| <kbd>ArrowRight</kbd> / <kbd>ArrowUp</kbd>  | Increase it                  |

Each arrow step moves the area by a fixed perceptual increment, so keyboard adjustment is usable rather than a crawl; there is no large-step modifier. For exact values or big jumps, type into `ColorPickerInput` — parsing accepts any CSS color string culori understands.

## Accessibility [#accessibility]

Every part is reachable with <kbd>Tab</kbd> and operable with arrows; sliders expose their current axis value to screen readers via the underlying slider role. The text input is the precision path — always include it. A picker used without any textual representation excludes people who cannot discriminate fine color differences, and makes matching an existing brand value impossible.

Never let the picked color alone carry meaning. A chosen red means nothing without adjacent text saying "danger" or "error"; color-vision deficiencies and forced-colors mode strip exactly that channel.

All surfaces come from tokens except the color being edited itself, which is inherently user content — check that surrounding labels stay legible against both themes. Layout mirrors in RTL because the parts stack with logical spacing; slider arrows keep their geometric meaning rather than flipping, which matches how color axes are conventionally drawn.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;color-picker&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      npm install culori
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/color-picker.tsx

```tsx
"use client"

import * as React from "react"
import { Slider as SliderPrimitive } from "@base-ui-components/react/slider"
import {
  clampChroma,
  clampRgb,
  converter,
  formatHex,
  formatHex8,
  formatHsl,
  formatRgb,
  parse,
  toGamut,
} from "culori"

import { Input } from "@/components/honest-ui/ui/input"
import { Select } from "@/components/honest-ui/ui/select"
import { cn } from "@/lib/utils"

const SUPPORTED_MODES = ["hex", "hsl", "rgb", "oklch"] as const
const CHROMA_MAX = 0.4
const CANVAS_RESOLUTION = 96
const AREA_STEP = 0.01
const AREA_STEP_LARGE = 0.1

type ModeType = (typeof SUPPORTED_MODES)[number]

type ColorObject = {
  l: number
  c: number
  h: number
  alpha?: number
}

type ColorPickerContextValue = ColorObject & {
  alpha: number
  mode: ModeType
  setColor: (color: Partial<ColorObject>) => void
  setMode: (mode: ModeType) => void
}

const toOklch = converter("oklch")
const toRgb = converter("rgb")
const toHsl = converter("hsl")
const toSrgb = toGamut("rgb", "oklch")
const FALLBACK_COLOR: ColorObject = { l: 1, c: 0, h: 0, alpha: 1 }

const ColorPickerContext = React.createContext<
  ColorPickerContextValue | undefined
>(undefined)

function useColorPicker() {
  const context = React.useContext(ColorPickerContext)

  if (!context) {
    throw new Error("ColorPicker components must be used within ColorPicker")
  }

  return context
}

function round(value: number, precision: number) {
  return Number.parseFloat(value.toFixed(precision))
}

function clamp01(value: number) {
  return Math.max(0, Math.min(1, value))
}

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

function parseColor(value: string): ColorObject {
  const parsed = parse(value)
  if (!parsed) return FALLBACK_COLOR

  const oklch = toOklch(parsed)
  if (!oklch) return FALLBACK_COLOR

  const chroma = oklch.c ?? 0
  return {
    l: oklch.l ?? 0,
    c: chroma,
    h:
      chroma === 0 || !Number.isFinite(oklch.h)
        ? 0
        : normalizeHue(oklch.h as number),
    alpha: oklch.alpha ?? 1,
  }
}

function formatOklch(color: ColorObject) {
  const lightness = round(color.l, 4)
  const chroma = round(color.c, 4)
  const hue = chroma === 0 ? 0 : round(normalizeHue(color.h), 2)
  const alpha = color.alpha ?? 1
  const body = `${lightness} ${chroma} ${hue}`

  return alpha === 1
    ? `oklch(${body})`
    : `oklch(${body} / ${round(alpha, 4)})`
}

function formatColor(
  value: string,
  format: "hex" | "rgb" | "hsl" | "oklch"
) {
  const parsed = parse(value)
  if (!parsed) return null

  if (format === "oklch") {
    const oklch = toOklch(parsed)
    if (!oklch) return null

    return formatOklch({
      l: oklch.l ?? 0,
      c: oklch.c ?? 0,
      h: Number.isFinite(oklch.h) ? (oklch.h as number) : 0,
      alpha: oklch.alpha ?? 1,
    })
  }

  const safe = toSrgb(parsed)
  if (!safe) return null

  if (format === "hex") {
    const hex =
      (safe.alpha ?? 1) === 1 ? formatHex(safe) : formatHex8(safe)
    return hex?.toUpperCase() ?? null
  }

  if (format === "hsl") return formatHsl(safe) ?? null
  return formatRgb(safe) ?? null
}

function getColorString(color: ColorObject, mode: ModeType) {
  if (mode === "oklch") return formatOklch(color)

  const rgb = toRgb({
    mode: "oklch",
    l: color.l,
    c: color.c,
    h: color.h,
    alpha: color.alpha ?? 1,
  })
  if (!rgb) return ""

  const clipped = clampRgb(rgb)
  if (mode === "hex") {
    const hex =
      (clipped.alpha ?? 1) === 1
        ? formatHex(clipped)
        : formatHex8(clipped)
    return hex?.toUpperCase() ?? ""
  }

  if (mode === "hsl") return formatHsl(clipped) ?? ""
  return formatRgb(clipped) ?? ""
}

function oklchToRgb(lightness: number, chroma: number, hue: number) {
  return toRgb({ mode: "oklch", l: lightness, c: chroma, h: hue })
}

type HslColor = {
  h: number
  s: number
  l: number
}

function oklchToHsl(color: ColorObject): HslColor {
  const hsl = toHsl({
    mode: "oklch",
    l: color.l,
    c: color.c,
    h: color.h,
    alpha: color.alpha ?? 1,
  })

  if (!hsl) return { h: color.h, s: 0, l: 100 }

  return {
    h:
      color.c <= 1e-6 || !Number.isFinite(hsl.h)
        ? color.h
        : normalizeHue(hsl.h as number),
    s: clamp01(hsl.s ?? 0) * 100,
    l: clamp01(hsl.l ?? 0) * 100,
  }
}

function hslToOklch(
  hue: number,
  saturation: number,
  lightness: number,
  alpha = 1
): ColorObject {
  const oklch = toOklch({
    mode: "hsl",
    h: normalizeHue(hue),
    s: clamp01(saturation / 100),
    l: clamp01(lightness / 100),
    alpha,
  })

  if (!oklch) return { l: 0, c: 0, h: hue, alpha }

  return {
    l: oklch.l ?? 0,
    c: oklch.c ?? 0,
    h: Number.isFinite(oklch.h) ? (oklch.h as number) : hue,
    alpha,
  }
}

function clampToSrgb(color: ColorObject): ColorObject {
  const result = clampChroma(
    {
      mode: "oklch",
      l: color.l,
      c: color.c,
      h: color.h,
      alpha: color.alpha ?? 1,
    },
    "oklch",
    "rgb"
  )

  return {
    l: result.l ?? color.l,
    c: result.c ?? 0,
    h: result.h ?? color.h,
    alpha: result.alpha ?? color.alpha ?? 1,
  }
}

type ColorPickerProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange"
> & {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string, mode: ModeType) => void
  defaultMode?: ModeType
  mode?: ModeType
  onModeChange?: (mode: ModeType) => void
}

function ColorPickerRoot({
  value,
  defaultValue = "#FFFFFF",
  onValueChange,
  defaultMode = "hex",
  mode: providedMode,
  onModeChange,
  className,
  ...props
}: ColorPickerProps) {
  const providedColor = React.useMemo(
    () => (value ? parseColor(value) : undefined),
    [value]
  )
  const [internalColor, setInternalColor] = React.useState(() =>
    parseColor(defaultValue)
  )
  const [internalMode, setInternalMode] = React.useState(defaultMode)
  const mode = providedMode ?? internalMode

  const rawColor = React.useMemo<ColorObject>(
    () => ({
      l: providedColor?.l ?? internalColor.l,
      c: providedColor?.c ?? internalColor.c,
      h: providedColor?.h ?? internalColor.h,
      alpha: providedColor?.alpha ?? internalColor.alpha ?? 1,
    }),
    [internalColor, providedColor]
  )
  const displayedColor = React.useMemo(
    () => (mode === "oklch" ? rawColor : clampToSrgb(rawColor)),
    [mode, rawColor]
  )
  const rawColorRef = React.useRef(rawColor)

  React.useEffect(() => {
    rawColorRef.current = rawColor
  }, [rawColor])

  const setColor = React.useCallback(
    (nextColor: Partial<ColorObject>) => {
      const next = { ...rawColorRef.current, ...nextColor }
      rawColorRef.current = next

      if (value === undefined) {
        setInternalColor(next)
      }

      onValueChange?.(getColorString(next, mode), mode)
    },
    [mode, onValueChange, value]
  )

  const setMode = React.useCallback(
    (nextMode: ModeType) => {
      if (providedMode === undefined) {
        setInternalMode(nextMode)
      }
      onModeChange?.(nextMode)
    },
    [onModeChange, providedMode]
  )

  const contextValue = React.useMemo<ColorPickerContextValue>(
    () => ({
      l: displayedColor.l,
      c: displayedColor.c,
      h: displayedColor.h,
      alpha: displayedColor.alpha ?? 1,
      mode,
      setColor,
      setMode,
    }),
    [displayedColor, mode, setColor, setMode]
  )

  return (
    <ColorPickerContext.Provider value={contextValue}>
      <div
        data-slot="color-picker"
        className={cn(
          "flex w-full flex-col gap-[var(--hui-space-4)]",
          className
        )}
        {...props}
      />
    </ColorPickerContext.Provider>
  )
}

ColorPickerRoot.displayName = "ColorPicker"

type ColorPickerAreaProps = React.ComponentProps<"div">

function ColorPickerArea(props: ColorPickerAreaProps) {
  const { mode } = useColorPicker()
  return mode === "oklch" ? (
    <OklchColorArea {...props} />
  ) : (
    <HslColorArea {...props} />
  )
}

ColorPickerArea.displayName = "ColorPicker.Area"

function getAreaPoint(
  event: React.PointerEvent<HTMLDivElement>,
  element: HTMLDivElement
) {
  const rect = element.getBoundingClientRect()
  return {
    x: clamp01((event.clientX - rect.left) / rect.width),
    y: clamp01((event.clientY - rect.top) / rect.height),
  }
}

function OklchColorArea({
  className,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onKeyDown,
  "aria-label": ariaLabel = "Color area, chroma and lightness",
  ...props
}: ColorPickerAreaProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  const draggingRef = React.useRef(false)
  const { l: lightness, c: chroma, h: hue, setColor } = useColorPicker()

  React.useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

    let cancelled = false
    const frame = requestAnimationFrame(() => {
      if (cancelled) return

      const context = canvas.getContext("2d")
      if (!context) return

      const image = context.createImageData(
        CANVAS_RESOLUTION,
        CANVAS_RESOLUTION
      )

      for (let y = 0; y < CANVAS_RESOLUTION; y += 1) {
        const nextLightness = 1 - y / (CANVAS_RESOLUTION - 1)
        for (let x = 0; x < CANVAS_RESOLUTION; x += 1) {
          const nextChroma =
            (x / (CANVAS_RESOLUTION - 1)) * CHROMA_MAX
          const rgb = oklchToRgb(nextLightness, nextChroma, hue)
          const index = (y * CANVAS_RESOLUTION + x) * 4

          image.data[index] = Math.round(clamp01(rgb?.r ?? 0.5) * 255)
          image.data[index + 1] = Math.round(
            clamp01(rgb?.g ?? 0.5) * 255
          )
          image.data[index + 2] = Math.round(
            clamp01(rgb?.b ?? 0.5) * 255
          )
          image.data[index + 3] = 255
        }
      }

      context.putImageData(image, 0, 0)
    })

    return () => {
      cancelled = true
      cancelAnimationFrame(frame)
    }
  }, [hue])

  const applyPosition = React.useCallback(
    (x: number, y: number) => {
      setColor({ c: clamp01(x) * CHROMA_MAX, l: 1 - clamp01(y) })
    },
    [setColor]
  )

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerDown?.(event)
    if (event.defaultPrevented) return

    event.preventDefault()
    draggingRef.current = true
    event.currentTarget.setPointerCapture(event.pointerId)
    const point = getAreaPoint(event, event.currentTarget)
    applyPosition(point.x, point.y)
  }

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event)
    if (!draggingRef.current || event.defaultPrevented) return

    const point = getAreaPoint(event, event.currentTarget)
    applyPosition(point.x, point.y)
  }

  const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => {
    draggingRef.current = false
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
  }

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event)
    if (event.defaultPrevented) return

    let x = clamp01(chroma / CHROMA_MAX)
    let y = clamp01(1 - lightness)
    const step = event.shiftKey ? AREA_STEP_LARGE : AREA_STEP

    switch (event.key) {
      case "ArrowLeft":
        x -= step
        break
      case "ArrowRight":
        x += step
        break
      case "ArrowUp":
        y -= step
        break
      case "ArrowDown":
        y += step
        break
      case "PageUp":
        y -= AREA_STEP_LARGE
        break
      case "PageDown":
        y += AREA_STEP_LARGE
        break
      case "Home":
        x = 0
        break
      case "End":
        x = 1
        break
      default:
        return
    }

    event.preventDefault()
    applyPosition(x, y)
  }

  const x = clamp01(chroma / CHROMA_MAX)
  const y = clamp01(1 - lightness)
  const valueText = `chroma ${Math.round(x * 100)}%, lightness ${Math.round(
    lightness * 100
  )}%`

  return (
    <div
      {...props}
      role="slider"
      tabIndex={0}
      aria-label={ariaLabel}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(x * 100)}
      aria-valuetext={valueText}
      data-slot="color-picker-area"
      className={cn(
        "relative aspect-square w-full touch-none overflow-hidden rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-primary)] select-none focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-inset)]",
        className
      )}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={(event) => {
        onPointerUp?.(event)
        stopDragging(event)
      }}
      onPointerCancel={(event) => {
        onPointerCancel?.(event)
        stopDragging(event)
      }}
      onKeyDown={handleKeyDown}
    >
      <canvas
        ref={canvasRef}
        width={CANVAS_RESOLUTION}
        height={CANVAS_RESOLUTION}
        className="block size-full"
        data-slot="color-picker-area-canvas"
      />
      <span
        aria-hidden="true"
        className="pointer-events-none absolute top-0 left-0 block size-[var(--hui-space-4)] rounded-full border-[3px] border-[var(--hui-color-foreground-base-emphasis)] shadow-[var(--hui-shadow-soft)]"
        style={{
          backgroundColor: formatOklch({
            l: lightness,
            c: chroma,
            h: hue,
          }),
          transform: "translate(-50%, -50%)",
          left: `${round(x * 100, 4)}%`,
          top: `${round(y * 100, 4)}%`,
        }}
        data-slot="color-picker-area-thumb"
      />
    </div>
  )
}

function HslColorArea({
  className,
  style,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onKeyDown,
  "aria-label": ariaLabel = "Color area, saturation and brightness",
  ...props
}: ColorPickerAreaProps) {
  const draggingRef = React.useRef(false)
  const { l: lightness, c: chroma, h: hue, setColor } = useColorPicker()
  const hsl = React.useMemo(
    () => oklchToHsl({ l: lightness, c: chroma, h: hue }),
    [chroma, hue, lightness]
  )
  const hueColor = `hsl(${round(hsl.h, 4)} 100% 50%)`

  const applyPosition = React.useCallback(
    (x: number, y: number) => {
      const nextX = clamp01(x)
      const saturation = nextX * 100
      const topLightness = nextX < 0.01 ? 100 : 50 + 50 * (1 - nextX)
      const next = hslToOklch(
        hsl.h,
        saturation,
        topLightness * (1 - clamp01(y))
      )
      setColor(next)
    },
    [hsl.h, setColor]
  )

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerDown?.(event)
    if (event.defaultPrevented) return

    event.preventDefault()
    draggingRef.current = true
    event.currentTarget.setPointerCapture(event.pointerId)
    const point = getAreaPoint(event, event.currentTarget)
    applyPosition(point.x, point.y)
  }

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event)
    if (!draggingRef.current || event.defaultPrevented) return

    const point = getAreaPoint(event, event.currentTarget)
    applyPosition(point.x, point.y)
  }

  const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => {
    draggingRef.current = false
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
  }

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event)
    if (event.defaultPrevented) return

    let x = clamp01(hsl.s / 100)
    const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x)
    let y = clamp01(1 - hsl.l / topLightness)
    const step = event.shiftKey ? AREA_STEP_LARGE : AREA_STEP

    switch (event.key) {
      case "ArrowLeft":
        x -= step
        break
      case "ArrowRight":
        x += step
        break
      case "ArrowUp":
        y -= step
        break
      case "ArrowDown":
        y += step
        break
      case "PageUp":
        y -= AREA_STEP_LARGE
        break
      case "PageDown":
        y += AREA_STEP_LARGE
        break
      case "Home":
        x = 0
        break
      case "End":
        x = 1
        break
      default:
        return
    }

    event.preventDefault()
    applyPosition(x, y)
  }

  const x = clamp01(hsl.s / 100)
  const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x)
  const y = clamp01(1 - hsl.l / topLightness)
  const valueText = `saturation ${Math.round(
    hsl.s
  )}%, brightness ${Math.round((hsl.l / topLightness) * 100)}%`

  return (
    <div
      {...props}
      role="slider"
      tabIndex={0}
      aria-label={ariaLabel}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(hsl.s)}
      aria-valuetext={valueText}
      data-slot="color-picker-area"
      className={cn(
        "relative aspect-square w-full touch-none overflow-hidden rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-primary)] select-none focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-inset)]",
        className
      )}
      style={{
        ...style,
        backgroundColor: hueColor,
        backgroundImage:
          "linear-gradient(0deg, rgb(0 0 0), transparent), linear-gradient(90deg, rgb(255 255 255), transparent)",
      }}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={(event) => {
        onPointerUp?.(event)
        stopDragging(event)
      }}
      onPointerCancel={(event) => {
        onPointerCancel?.(event)
        stopDragging(event)
      }}
      onKeyDown={handleKeyDown}
    >
      <span
        aria-hidden="true"
        className="pointer-events-none absolute block size-[var(--hui-space-4)] rounded-full border-[3px] border-[var(--hui-color-foreground-base-emphasis)] shadow-[var(--hui-shadow-soft)]"
        style={{
          backgroundColor: `hsl(${round(hsl.h, 4)} ${round(hsl.s, 4)}% ${round(hsl.l, 4)}%)`,
          left: `${round(x * 100, 4)}%`,
          top: `${round(y * 100, 4)}%`,
          transform: "translate(-50%, -50%)",
        }}
        data-slot="color-picker-area-thumb"
      />
    </div>
  )
}

const sliderRootClassName =
  "relative flex w-full touch-none items-center select-none data-disabled:opacity-50"
const sliderControlClassName = "flex w-full items-center"
const sliderTrackClassName =
  "relative h-[var(--hui-space-4)] w-full grow overflow-hidden rounded-[var(--hui-radius-3)] border-[0.5px] border-[var(--hui-color-border-base-primary)]"
const sliderIndicatorClassName =
  "absolute h-full rounded-[var(--hui-radius-full)] bg-transparent"
const sliderThumbClassName =
  "absolute block size-[var(--hui-space-4)] rounded-full border-[3px] border-[var(--hui-color-foreground-base-emphasis)] shadow-[var(--hui-shadow-soft)] outline-none focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-accent)]"

type ColorPickerHueProps = Omit<
  SliderPrimitive.Root.Props,
  "defaultValue" | "max" | "min" | "onValueChange" | "step" | "value"
>

function ColorPickerHue({ className, ...props }: ColorPickerHueProps) {
  const { l: lightness, c: chroma, h: hue, mode, setColor } =
    useColorPicker()
  const isOklch = mode === "oklch"
  const hsl = React.useMemo(
    () => (isOklch ? null : oklchToHsl({ l: lightness, c: chroma, h: hue })),
    [chroma, hue, isOklch, lightness]
  )
  const value = isOklch ? hue : (hsl?.h ?? 0)

  const handleValueChange = (nextValue: number | number[]) => {
    const nextHue = Array.isArray(nextValue) ? (nextValue[0] ?? 0) : nextValue

    if (isOklch) {
      setColor({ h: nextHue })
      return
    }

    if (!hsl) return
    setColor(hslToOklch(nextHue, hsl.s, hsl.l))
  }

  return (
    <SliderPrimitive.Root
      {...props}
      className={cn(sliderRootClassName, className)}
      max={360}
      step={isOklch ? 0.1 : 1}
      value={value}
      onValueChange={handleValueChange}
      data-slot="color-picker-hue"
    >
      <SliderPrimitive.Control
        className={sliderControlClassName}
        data-slot="color-picker-slider-control"
      >
        <SliderPrimitive.Track
          className={sliderTrackClassName}
          style={{
            background: isOklch
              ? "linear-gradient(90deg in oklch, oklch(0.7 0.18 0), oklch(0.7 0.18 60), oklch(0.7 0.18 120), oklch(0.7 0.18 180), oklch(0.7 0.18 240), oklch(0.7 0.18 300), oklch(0.7 0.18 360))"
              : "linear-gradient(90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)",
          }}
          data-slot="color-picker-slider-track"
        >
          <SliderPrimitive.Indicator
            className={sliderIndicatorClassName}
            data-slot="color-picker-slider-range"
          />
          <SliderPrimitive.Thumb
            className={sliderThumbClassName}
            aria-label={isOklch ? "OKLCH hue" : "HSL hue"}
            style={{
              backgroundColor: `hsl(${round(value, 4)} 100% 50%)`,
            }}
            data-slot="color-picker-slider-thumb"
          />
        </SliderPrimitive.Track>
      </SliderPrimitive.Control>
    </SliderPrimitive.Root>
  )
}

ColorPickerHue.displayName = "ColorPicker.Hue"

type ColorPickerAlphaProps = Omit<
  SliderPrimitive.Root.Props,
  "defaultValue" | "max" | "min" | "onValueChange" | "step" | "value"
>

function ColorPickerAlpha({ className, ...props }: ColorPickerAlphaProps) {
  const { l: lightness, c: chroma, h: hue, alpha, setColor } =
    useColorPicker()

  return (
    <SliderPrimitive.Root
      {...props}
      className={cn(sliderRootClassName, className)}
      max={100}
      step={1}
      value={alpha * 100}
      onValueChange={(nextValue) => {
        const value = Array.isArray(nextValue)
          ? (nextValue[0] ?? 100)
          : nextValue
        setColor({ alpha: value / 100 })
      }}
      data-slot="color-picker-alpha"
    >
      <SliderPrimitive.Control
        className={sliderControlClassName}
        data-slot="color-picker-slider-control"
      >
        <SliderPrimitive.Track
          className={cn(
            sliderTrackClassName,
            "bg-[var(--hui-color-background-base-primary)]"
          )}
          style={{
            backgroundImage:
              "linear-gradient(45deg, var(--hui-color-border-base-primary) 25%, transparent 25%), linear-gradient(-45deg, var(--hui-color-border-base-primary) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--hui-color-border-base-primary) 75%), linear-gradient(-45deg, transparent 75%, var(--hui-color-border-base-primary) 75%)",
            backgroundPosition: "0 0, 0 6px, 6px -6px, -6px 0",
            backgroundSize: "12px 12px",
          }}
          data-slot="color-picker-slider-track"
        >
          <span
            aria-hidden="true"
            className="absolute inset-0 rounded-[var(--hui-radius-3)]"
            style={{
              backgroundImage: `linear-gradient(90deg, transparent, ${formatOklch(
                { l: lightness, c: chroma, h: hue }
              )})`,
            }}
            data-slot="color-picker-alpha-gradient"
          />
          <SliderPrimitive.Indicator
            className={sliderIndicatorClassName}
            data-slot="color-picker-slider-range"
          />
          <SliderPrimitive.Thumb
            className={sliderThumbClassName}
            aria-label="Alpha"
            style={{
              backgroundColor: formatOklch({
                l: lightness,
                c: chroma,
                h: hue,
                alpha,
              }),
            }}
            data-slot="color-picker-slider-thumb"
          />
        </SliderPrimitive.Track>
      </SliderPrimitive.Control>
    </SliderPrimitive.Root>
  )
}

ColorPickerAlpha.displayName = "ColorPicker.Alpha"

type ColorPickerInputProps = Omit<
  React.ComponentProps<typeof Input>,
  "defaultValue" | "readOnly" | "value"
> & {
  copyable?: boolean
}

function ColorPickerInput({
  copyable = false,
  className,
  ...props
}: ColorPickerInputProps) {
  const { l: lightness, c: chroma, h: hue, alpha, mode } = useColorPicker()
  const [copied, setCopied] = React.useState(false)
  const copiedTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
    null
  )
  const value = React.useMemo(
    () => getColorString({ l: lightness, c: chroma, h: hue, alpha }, mode),
    [alpha, chroma, hue, lightness, mode]
  )

  React.useEffect(
    () => () => {
      if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current)
    },
    []
  )

  const copyValue = async () => {
    try {
      await navigator.clipboard.writeText(value)
      setCopied(true)
      if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current)
      copiedTimerRef.current = setTimeout(() => setCopied(false), 1500)
    } catch {
      setCopied(false)
    }
  }

  return (
    <span className="relative flex min-w-0 flex-1" data-slot="color-picker-input-wrapper">
      <Input
        {...props}
        value={value}
        readOnly
        className={cn(
          copyable && "[&_[data-slot=input]]:pr-[var(--hui-space-9)]",
          className
        )}
        data-slot="color-picker-input"
      />
      {copyable && (
        <button
          type="button"
          aria-label={copied ? "Color copied" : "Copy color"}
          title={copied ? "Copied" : "Copy color"}
          onClick={copyValue}
          className="absolute top-1/2 right-[var(--hui-space-2)] inline-flex size-[var(--hui-space-6)] -translate-y-1/2 cursor-pointer items-center justify-center rounded-[var(--hui-radius-1)] border-0 bg-transparent text-[var(--hui-color-foreground-base-secondary)] outline-none hover:bg-[var(--hui-color-background-base-primary-hover)] hover:text-[var(--hui-color-foreground-base-primary)] focus-visible:[outline:var(--hui-focus-ring)]"
          data-slot="color-picker-copy"
        >
          {copied ? (
            <svg viewBox="0 0 24 24" aria-hidden="true" className="size-3.5 fill-none stroke-current">
              <path d="m5 12 4 4L19 6" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          ) : (
            <svg viewBox="0 0 24 24" aria-hidden="true" className="size-3.5 fill-none stroke-current">
              <rect x="8" y="8" width="11" height="11" rx="2" strokeWidth="1.5" />
              <path d="M16 8V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h1" strokeWidth="1.5" strokeLinecap="round" />
            </svg>
          )}
        </button>
      )}
    </span>
  )
}

ColorPickerInput.displayName = "ColorPicker.Input"

type ColorPickerModeProps = React.ComponentProps<typeof Select.Trigger> & {
  options?: readonly ModeType[]
}

function ColorPickerMode({
  className,
  options = SUPPORTED_MODES,
  ...props
}: ColorPickerModeProps) {
  const { mode, setMode } = useColorPicker()

  return (
    <Select
      value={mode}
      onValueChange={(nextMode) => {
        if (nextMode && SUPPORTED_MODES.includes(nextMode as ModeType)) {
          setMode(nextMode as ModeType)
        }
      }}
    >
      <Select.Trigger
        {...props}
        className={cn("min-w-[var(--hui-space-15)] shrink-0", className)}
        data-slot="color-picker-mode"
      >
        <Select.Value>{mode.toUpperCase()}</Select.Value>
      </Select.Trigger>
      <Select.Content data-slot="color-picker-mode-content">
        {options.map((option) => (
          <Select.Item key={option} value={option}>
            {option.toUpperCase()}
          </Select.Item>
        ))}
      </Select.Content>
    </Select>
  )
}

ColorPickerMode.displayName = "ColorPicker.Mode"

const ColorPicker = Object.assign(ColorPickerRoot, {
  Area: ColorPickerArea,
  Hue: ColorPickerHue,
  Alpha: ColorPickerAlpha,
  Input: ColorPickerInput,
  Mode: ColorPickerMode,
})

export {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerHue,
  ColorPickerInput,
  ColorPickerMode,
  ColorPickerRoot,
  SUPPORTED_MODES,
  formatColor,
  type ColorObject,
  type ColorPickerAlphaProps,
  type ColorPickerAreaProps,
  type ColorPickerHueProps,
  type ColorPickerInputProps,
  type ColorPickerModeProps,
  type ColorPickerProps,
  type ModeType,
}

```

      
        Make sure these primitives are installed:
      

      <CliBlock commands="[&#x22;input&#x22;, &#x22;select&#x22;]" />

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerHue,
  ColorPickerInput,
  ColorPickerMode,
} from "@/components/ui/color-picker";
```

```tsx
<ColorPicker defaultValue="#7C3AED">
  <ColorPickerArea />
  <ColorPickerHue />
  <ColorPickerAlpha />
  <div className="flex gap-2">
    <ColorPickerInput />
    <ColorPickerMode />
  </div>
</ColorPicker>
```

The value is a controlled or uncontrolled string in the active mode's format.

## Don't do this [#dont-do-this]

### Swatch-only pickers with no way to type [#swatch-only-pickers-with-no-way-to-type]

```tsx
// Bad
<ColorPicker>
  <ColorPickerArea />
  <ColorPickerHue />
</ColorPicker>
```

```tsx
// Good
<ColorPicker>
  <ColorPickerArea />
  <ColorPickerHue />
  <ColorPickerInput />
</ColorPicker>
```

Without text entry there is no way to enter `#0F52BA` exactly, match a value from another tool, or adjust color at all without fine visual-motor control. The input is not an extra — it is the accessible, precise path, and drag-only interfaces fail both screen-reader users and anyone who knows the value they want.

### Letting the picked color carry the meaning [#letting-the-picked-color-carry-the-meaning]

```tsx
// Bad
<div style={{ background: picked }} aria-label={picked} />
```

```tsx
// Good
<span className="inline-flex items-center gap-2">
  <span style={{ background: picked }} aria-hidden="true" className="size-4 rounded-full border" />
  {colorName ?? picked}
</span>
```

A color chip labeled only with its own hex value announces gibberish ("four two four two four two") and conveys nothing to color-blind readers. Whatever the color signifies — category, status, team — say it in text next to the swatch.

### Letting picked colors pick unreadable text [#letting-picked-colors-pick-unreadable-text]

```tsx
// Bad
<div style={{ background: picked }}>
  <span>Save</span> // white text on a lemon-yellow pick
</div>
```

```tsx
// Good
const foreground = isDark(picked) ? "#FFFFFF" : "#1A1A1A";

<div style={{ background: picked, color: foreground }}>
  <span>Save</span>
</div>
```

Once a person can choose any background, text placed on it inherits every possible contrast failure. Compute the foreground from the chosen value's lightness instead of hard-coding it — OKLCH lightness makes that check a threshold, not a judgment call.

## Examples [#examples]

### Controlled with mode switching [#controlled-with-mode-switching]

```tsx
"use client"

import * as React from "react"

import { ColorPicker } from "@/components/honest-ui/ui/color-picker"

export function ColorPickerControlled() {
  const [color, setColor] = React.useState("#DD3E8B")

  return (
    <div className="flex w-full max-w-[12rem] flex-col gap-[var(--hui-space-4)]">
      <div className="flex items-center gap-[var(--hui-space-3)]">
        <span
          aria-hidden="true"
          className="size-[var(--hui-space-7)] rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-primary)]"
          style={{ backgroundColor: color }}
        />
        <span className="text-[length:var(--hui-font-size-small)] text-[var(--hui-color-foreground-base-secondary)]">
          {color}
        </span>
      </div>
      <ColorPicker
        value={color}
        onValueChange={setColor}
        className="w-full gap-[var(--hui-space-3)]"
      >
        <ColorPicker.Area />
        <ColorPicker.Hue />
        <ColorPicker.Alpha />
        <div className="flex items-center gap-[var(--hui-space-3)]">
          <ColorPicker.Input copyable />
          <ColorPicker.Mode />
        </div>
      </ColorPicker>
    </div>
  )
}

```

### Full picker [#full-picker]

```tsx
"use client"

import { ColorPicker } from "@/components/honest-ui/ui/color-picker"

export function ColorPickerDemo() {
  return (
    <ColorPicker
      defaultValue="#3E63DD"
      className="w-full max-w-[12rem] gap-[var(--hui-space-3)]"
    >
      <ColorPicker.Area />
      <ColorPicker.Hue />
      <ColorPicker.Alpha />
      <div className="flex items-center gap-[var(--hui-space-3)]">
        <ColorPicker.Input copyable />
        <ColorPicker.Mode />
      </div>
    </ColorPicker>
  )
}

```

## API reference [#api-reference]

| Part              | Prop                     | Type                                      |
| ----------------- | ------------------------ | ----------------------------------------- |
| `ColorPicker`     | `value` / `defaultValue` | `string` (any supported format)           |
| `ColorPicker`     | `onValueChange`          | `(value: string, mode: ModeType) => void` |
| `ColorPicker`     | `mode` / `defaultMode`   | `"hex" \| "hsl" \| "rgb" \| "oklch"`      |
| `ColorPickerMode` | `onModeChange`           | `(mode: ModeType) => void`                |

Out-of-sRGB-range OKLCH values round-trip unchanged; the rendered preview shows the clamped equivalent. Parsing uses [culori](https://culorijs.org/), so any valid CSS color string is accepted as input.
