wui
组件

颜色选择器 Color Picker

面向设计 Token 的 OKLCH 颜色选择器,支持感知色彩通道、透明度和精确 CSS 值。

基础示例

Loading…
pnpm dlx wui@latest add @wui/color-picker
components/ui/color-picker.tsx
"use client"

import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

interface OklchColor {
  lightness: number
  chroma: number
  hue: number
  alpha: number
}

interface RgbColor {
  red: number
  green: number
  blue: number
  alpha: number
}

interface HsvColor {
  hue: number
  saturation: number
  value: number
}

type PickerMode = "rgb" | "oklch"

const defaultSwatches = [
  "oklch(0.546 0.245 262.881)", // wui-token-audit-allow -- picker color data
  "oklch(0.585 0.233 277.117)", // wui-token-audit-allow -- picker color data
  "oklch(0.577 0.245 27.325)", // wui-token-audit-allow -- picker color data
  "oklch(0.666 0.179 58.318)", // wui-token-audit-allow -- picker color data
  "oklch(0.527 0.154 150.069)", // wui-token-audit-allow -- picker color data
  "oklch(0.6 0.118 184.704)", // wui-token-audit-allow -- picker color data
]

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

function parseColorValue(value: string): OklchColor | null {
  const match = value
    .trim()
    .match(
      /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)$/i // wui-token-audit-allow -- parser grammar
    )
  if (!match) return null

  const lightness = Number(match[1]) / (match[2] === "%" ? 100 : 1)
  const alpha = match[5]
    ? Number(match[5]) / (match[6] === "%" ? 100 : 1)
    : 1

  return {
    lightness: clamp(lightness, 0, 1),
    chroma: clamp(Number(match[3]), 0, 0.4),
    hue: ((Number(match[4]) % 360) + 360) % 360,
    alpha: clamp(alpha, 0, 1),
  }
}

function formatNumber(value: number, digits = 3) {
  return Number(value.toFixed(digits)).toString()
}

function formatColorValue(color: OklchColor, showAlpha: boolean) {
  const channels = `${formatNumber(color.lightness)} ${formatNumber(color.chroma)} ${formatNumber(color.hue, 1)}`
  if (!showAlpha || color.alpha >= 0.999) return `oklch(${channels})` // wui-token-audit-allow -- generated color

  return `oklch(${channels} / ${formatNumber(color.alpha * 100, 1)}%)` // wui-token-audit-allow -- generated color
}

function parseRgbValue(value: string): RgbColor | null {
  const match = value.trim().match(/^rgba?\((.+)\)$/i)
  if (!match) return null

  const [channels, alphaChannel] = match[1].split("/").map((part) => part.trim())
  const parts = channels.replaceAll(",", " ").split(/\s+/).filter(Boolean)
  if (parts.length !== 3) return null

  const toChannel = (part: string) =>
    clamp(
      part.endsWith("%") ? (Number(part.slice(0, -1)) / 100) * 255 : Number(part),
      0,
      255
    )
  const alpha = alphaChannel
    ? clamp(
        alphaChannel.endsWith("%")
          ? Number(alphaChannel.slice(0, -1)) / 100
          : Number(alphaChannel),
        0,
        1
      )
    : 1

  if ([...parts.map(toChannel), alpha].some(Number.isNaN)) return null

  return {
    red: toChannel(parts[0]),
    green: toChannel(parts[1]),
    blue: toChannel(parts[2]),
    alpha,
  }
}

function formatRgbValue(color: RgbColor, showAlpha: boolean) {
  const channels = `${Math.round(color.red)} ${Math.round(color.green)} ${Math.round(color.blue)}`
  if (!showAlpha || color.alpha >= 0.999) return `rgb(${channels})` // wui-token-audit-allow -- generated color

  return `rgb(${channels} / ${formatNumber(color.alpha * 100, 1)}%)` // wui-token-audit-allow -- generated color
}

function linearToDisplayChannel(channel: number) {
  return channel <= 0.0031308
    ? 12.92 * channel
    : 1.055 * channel ** (1 / 2.4) - 0.055
}

function srgbToLinear(channel: number) {
  return channel <= 0.04045
    ? channel / 12.92
    : ((channel + 0.055) / 1.055) ** 2.4
}

function perceptualToChannels(color: OklchColor): RgbColor {
  const angle = (color.hue * Math.PI) / 180
  const a = color.chroma * Math.cos(angle)
  const b = color.chroma * Math.sin(angle)
  const lRoot = color.lightness + 0.3963377774 * a + 0.2158037573 * b
  const mRoot = color.lightness - 0.1055613458 * a - 0.0638541728 * b
  const sRoot = color.lightness - 0.0894841775 * a - 1.291485548 * b
  const l = lRoot ** 3
  const m = mRoot ** 3
  const s = sRoot ** 3

  return {
    red: clamp(
      linearToDisplayChannel(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s) *
        255,
      0,
      255
    ),
    green: clamp(
      linearToDisplayChannel(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s) *
        255,
      0,
      255
    ),
    blue: clamp(
      linearToDisplayChannel(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s) *
        255,
      0,
      255
    ),
    alpha: color.alpha,
  }
}

function rgbToPerceptual(color: RgbColor): OklchColor {
  const red = srgbToLinear(color.red / 255)
  const green = srgbToLinear(color.green / 255)
  const blue = srgbToLinear(color.blue / 255)
  const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue)
  const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue)
  const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue)
  const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
  const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
  const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s

  return {
    lightness: clamp(lightness, 0, 1),
    chroma: clamp(Math.sqrt(a * a + b * b), 0, 0.4),
    hue: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360,
    alpha: color.alpha,
  }
}

function channelsToHsv(color: RgbColor): HsvColor {
  const red = color.red / 255
  const green = color.green / 255
  const blue = color.blue / 255
  const max = Math.max(red, green, blue)
  const min = Math.min(red, green, blue)
  const delta = max - min
  let hue = 0

  if (delta > 0) {
    if (max === red) hue = 60 * (((green - blue) / delta) % 6)
    else if (max === green) hue = 60 * ((blue - red) / delta + 2)
    else hue = 60 * ((red - green) / delta + 4)
  }

  return {
    hue: (hue + 360) % 360,
    saturation: max === 0 ? 0 : delta / max,
    value: max,
  }
}

function hsvToChannels(color: HsvColor, alpha: number): RgbColor {
  const chroma = color.value * color.saturation
  const segment = color.hue / 60
  const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
  const offset = color.value - chroma
  let channels: [number, number, number]

  if (segment < 1) channels = [chroma, secondary, 0]
  else if (segment < 2) channels = [secondary, chroma, 0]
  else if (segment < 3) channels = [0, chroma, secondary]
  else if (segment < 4) channels = [0, secondary, chroma]
  else if (segment < 5) channels = [secondary, 0, chroma]
  else channels = [chroma, 0, secondary]

  return {
    red: (channels[0] + offset) * 255,
    green: (channels[1] + offset) * 255,
    blue: (channels[2] + offset) * 255,
    alpha,
  }
}

export interface ColorPickerProps {
  /** CSS OKLCH color value. */
  value: string
  /** Called continuously while a channel or exact value changes. */
  onValueChange: (value: string) => void
  /** Accessible name for the trigger. */
  label?: string
  /** Show the alpha channel control. @default true */
  showAlpha?: boolean
  /** Quick-pick OKLCH swatches. */
  swatches?: string[]
  className?: string
  disabled?: boolean
}

/** An OKLCH color picker with perceptual channel controls and exact CSS input. */
function ColorPicker({
  value,
  onValueChange,
  label = "选择颜色",
  showAlpha = true,
  swatches = defaultSwatches,
  className,
  disabled,
}: ColorPickerProps) {
  const [open, setOpen] = React.useState(false)
  const [mode, setMode] = React.useState<PickerMode>("rgb")
  const parsedPerceptual = parseColorValue(value)
  const parsedRgb = parseRgbValue(value)
  const color =
    parsedPerceptual ??
    (parsedRgb ? rgbToPerceptual(parsedRgb) : null) ?? {
      lightness: 0.5,
      chroma: 0,
      hue: 0,
      alpha: 1,
    }
  const rgb = parsedRgb ?? perceptualToChannels(color)
  const [hsv, setHsv] = React.useState<HsvColor>(() => channelsToHsv(rgb))

  React.useEffect(() => {
    if (!open) setHsv(channelsToHsv(rgb))
  }, [open, value])

  function updateColor(patch: Partial<OklchColor>) {
    onValueChange(formatColorValue({ ...color, ...patch }, showAlpha))
  }

  function updateChannels(patch: Partial<RgbColor>) {
    const nextRgb = { ...rgb, ...patch }
    if (!("alpha" in patch && Object.keys(patch).length === 1)) {
      setHsv(channelsToHsv(nextRgb))
    }
    const nextValue = formatRgbValue(nextRgb, showAlpha)
    onValueChange(nextValue)
  }

  function updateHsv(patch: Partial<HsvColor>) {
    const nextHsv = { ...hsv, ...patch }
    const nextValue = formatRgbValue(
      hsvToChannels(nextHsv, rgb.alpha),
      showAlpha
    )
    setHsv(nextHsv)
    onValueChange(nextValue)
  }

  function selectSwatch(swatch: string) {
    const swatchColor = parseColorValue(swatch)
    if (mode === "rgb" && swatchColor) {
      const nextRgb = perceptualToChannels(swatchColor)
      const nextValue = formatRgbValue(nextRgb, showAlpha)
      setHsv(channelsToHsv(nextRgb))
      onValueChange(nextValue)
      return
    }

    onValueChange(swatch)
  }

  return (
    <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
      <PopoverPrimitive.Trigger asChild>
        <button
          type="button"
          aria-label={label}
          disabled={disabled}
          className={cn(
            "relative size-9 shrink-0 overflow-hidden rounded-md border border-input bg-background p-1 shadow-xs outline-none transition-[border-color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50",
            className
          )}
        >
          <span
            className="block size-full rounded-sm border border-border/60"
            style={{ backgroundColor: value }}
          />
        </button>
      </PopoverPrimitive.Trigger>

      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Content
          align="end"
          sideOffset={6}
          className="z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none"
        >
          <div className="mb-4 flex border-b" role="tablist" aria-label="颜色模式">
            {(["rgb", "oklch"] as const).map((currentMode) => (
              <button
                key={currentMode}
                type="button"
                role="tab"
                aria-selected={mode === currentMode}
                className={cn(
                  "relative flex-1 pb-2.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground",
                  mode === currentMode &&
                    "text-foreground after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:bg-foreground"
                )}
                onClick={() => {
                  if (currentMode === "rgb") setHsv(channelsToHsv(rgb))
                  setMode(currentMode)
                }}
              >
                {currentMode === "rgb" ? "基础 RGB" : "专业 OKLCH"}
              </button>
            ))}
          </div>

          {mode === "rgb" ? (
            <div>
              <ColorPlane
                hue={hsv.hue}
                saturation={hsv.saturation}
                value={hsv.value}
                onChange={(saturation, value) =>
                  updateHsv({ saturation, value })
                }
              />

              <div className="mt-3">
                <ColorChannel
                  label="色相"
                  value={hsv.hue}
                  min={0}
                  max={360}
                  step={1}
                  background={"linear-gradient(to right, hsl(0 100% 50%), hsl(60 100% 50%), hsl(120 100% 50%), hsl(180 100% 50%), hsl(240 100% 50%), hsl(300 100% 50%), hsl(360 100% 50%))" /* wui-token-audit-allow -- hue spectrum */}
                  display={`${Math.round(hsv.hue)}°`}
                  onChange={(hue) => updateHsv({ hue })}
                />
              </div>

              <div className="mt-3 grid grid-cols-3 gap-2">
                <RgbChannel
                  label="R"
                  value={rgb.red}
                  onChange={(red) => updateChannels({ red })}
                />
                <RgbChannel
                  label="G"
                  value={rgb.green}
                  onChange={(green) => updateChannels({ green })}
                />
                <RgbChannel
                  label="B"
                  value={rgb.blue}
                  onChange={(blue) => updateChannels({ blue })}
                />
              </div>

              {showAlpha ? (
                <div className="mt-3">
                  <ColorChannel
                    label="透明度"
                    value={rgb.alpha}
                    min={0}
                    max={1}
                    step={0.01}
                    background={`linear-gradient(to right, transparent, rgb(${rgb.red} ${rgb.green} ${rgb.blue}))` /* wui-token-audit-allow -- alpha gradient */}
                    display={`${Math.round(rgb.alpha * 100)}%`}
                    onChange={(alpha) => updateChannels({ alpha })}
                  />
                </div>
              ) : null}
            </div>
          ) : (
            <div>
              <div
                className="h-16 rounded-md border border-border"
                style={{ backgroundColor: value }}
              />

              <div className="mt-4 space-y-3">
                <ColorChannel
                  label="明度"
                  value={color.lightness}
                  min={0}
                  max={1}
                  step={0.005}
                  background={`linear-gradient(to right, oklch(0 ${color.chroma} ${color.hue}), oklch(1 ${color.chroma} ${color.hue}))` /* wui-token-audit-allow -- picker gradient */}
                  display={`${Math.round(color.lightness * 100)}%`}
                  onChange={(lightness) => updateColor({ lightness })}
                />
                <ColorChannel
                  label="色度"
                  value={color.chroma}
                  min={0}
                  max={0.4}
                  step={0.002}
                  background={`linear-gradient(to right, oklch(${color.lightness} 0 ${color.hue}), oklch(${color.lightness} 0.4 ${color.hue}))` /* wui-token-audit-allow -- picker gradient */}
                  display={formatNumber(color.chroma)}
                  onChange={(chroma) => updateColor({ chroma })}
                />
                <ColorChannel
                  label="色相"
                  value={color.hue}
                  min={0}
                  max={360}
                  step={1}
                  background={"linear-gradient(to right, oklch(0.7 0.2 0), oklch(0.7 0.2 60), oklch(0.7 0.2 120), oklch(0.7 0.2 180), oklch(0.7 0.2 240), oklch(0.7 0.2 300), oklch(0.7 0.2 360))" /* wui-token-audit-allow -- hue spectrum */}
                  display={`${Math.round(color.hue)}°`}
                  onChange={(hue) => updateColor({ hue })}
                />
                {showAlpha ? (
                  <ColorChannel
                    label="透明度"
                    value={color.alpha}
                    min={0}
                    max={1}
                    step={0.01}
                    background={`linear-gradient(to right, transparent, oklch(${color.lightness} ${color.chroma} ${color.hue}))` /* wui-token-audit-allow -- alpha gradient */}
                    display={`${Math.round(color.alpha * 100)}%`}
                    onChange={(alpha) => updateColor({ alpha })}
                  />
                ) : null}
              </div>
            </div>
          )}

          {swatches.length > 0 ? (
            <div className="mt-4 border-t pt-4">
              <p className="mb-2 text-xs font-medium text-muted-foreground">
                常用颜色
              </p>
              <div className="flex flex-wrap gap-2">
                {swatches.map((swatch) => (
                  <button
                    key={swatch}
                    type="button"
                    aria-label={`使用 ${swatch}`}
                    aria-pressed={value === swatch}
                    className="size-7 rounded-md border border-border p-0.5 outline-none transition-transform hover:scale-105 focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-pressed:ring-2 aria-pressed:ring-foreground"
                    onClick={() => selectSwatch(swatch)}
                  >
                    <span
                      className="block size-full rounded-sm"
                      style={{ backgroundColor: swatch }}
                    />
                  </button>
                ))}
              </div>
            </div>
          ) : null}

          <label className="mt-4 block border-t pt-4">
            <span className="mb-2 block text-xs font-medium text-muted-foreground">
              CSS 值
            </span>
            <input
              value={value}
              spellCheck={false}
              className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs outline-none transition-[border-color,box-shadow] focus:border-ring focus:ring-[3px] focus:ring-ring/25"
              onChange={(event) => onValueChange(event.target.value)}
            />
          </label>

          <PopoverPrimitive.Arrow className="fill-border" />
        </PopoverPrimitive.Content>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  )
}

function ColorPlane({
  hue,
  saturation,
  value,
  onChange,
}: {
  hue: number
  saturation: number
  value: number
  onChange: (saturation: number, value: number) => void
}) {
  function updateFromPointer(event: React.PointerEvent<HTMLDivElement>) {
    const rect = event.currentTarget.getBoundingClientRect()
    const nextSaturation = clamp((event.clientX - rect.left) / rect.width, 0, 1)
    const nextValue = 1 - clamp((event.clientY - rect.top) / rect.height, 0, 1)
    onChange(nextSaturation, nextValue)
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    const step = event.shiftKey ? 0.1 : 0.02
    if (event.key === "ArrowLeft") onChange(clamp(saturation - step, 0, 1), value)
    else if (event.key === "ArrowRight")
      onChange(clamp(saturation + step, 0, 1), value)
    else if (event.key === "ArrowUp")
      onChange(saturation, clamp(value + step, 0, 1))
    else if (event.key === "ArrowDown")
      onChange(saturation, clamp(value - step, 0, 1))
    else return

    event.preventDefault()
  }

  return (
    <div
      role="slider"
      tabIndex={0}
      aria-label="饱和度与亮度"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(saturation * 100)}
      aria-valuetext={`饱和度 ${Math.round(saturation * 100)}%,亮度 ${Math.round(value * 100)}%`}
      className="relative h-36 touch-none cursor-crosshair overflow-hidden rounded-md border border-border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/30"
      style={{ backgroundColor: `hsl(${hue} 100% 50%)` } /* wui-token-audit-allow -- interactive color plane */}
      onPointerDown={(event) => {
        event.currentTarget.setPointerCapture(event.pointerId)
        updateFromPointer(event)
      }}
      onPointerMove={(event) => {
        if (event.currentTarget.hasPointerCapture(event.pointerId)) {
          updateFromPointer(event)
        }
      }}
      onKeyDown={handleKeyDown}
    >
      <span
        aria-hidden
        className="absolute inset-0"
        style={{
          backgroundImage:
            "linear-gradient(to right, white, transparent), linear-gradient(to bottom, transparent, black)",
        }}
      />
      <span
        aria-hidden
        className="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background shadow-[0_0_0_1px_var(--foreground)]"
        style={{ left: `${saturation * 100}%`, top: `${(1 - value) * 100}%` }}
      />
    </div>
  )
}

function ColorChannel({
  label,
  value,
  min,
  max,
  step,
  background,
  display,
  onChange,
}: {
  label: string
  value: number
  min: number
  max: number
  step: number
  background: string
  display: string
  onChange: (value: number) => void
}) {
  return (
    <label className="grid grid-cols-[3rem_1fr_2.75rem] items-center gap-2 text-xs">
      <span className="text-muted-foreground">{label}</span>
      <input
        type="range"
        value={value}
        min={min}
        max={max}
        step={step}
        className="h-3 w-full cursor-pointer appearance-none rounded-full outline-none [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-foreground [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:shadow-sm"
        style={{ background }}
        onChange={(event) => onChange(Number(event.target.value))}
      />
      <span className="block w-11 text-right font-mono text-[11px] tabular-nums">
        {display}
      </span>
    </label>
  )
}

function RgbChannel({
  label,
  value,
  onChange,
}: {
  label: string
  value: number
  onChange: (value: number) => void
}) {
  return (
    <label className="flex h-10 min-w-0 items-center justify-between gap-1 overflow-hidden rounded-md border border-input bg-background px-2 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/25">
      <span className="shrink-0 text-[10px] font-medium text-muted-foreground">
        {label}
      </span>
      <input
        type="number"
        value={Math.round(value)}
        min={0}
        max={255}
        className="w-[3ch] min-w-[3ch] appearance-none bg-transparent text-right font-mono text-xs tabular-nums outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
        onChange={(event) =>
          onChange(clamp(Number(event.target.value), 0, 255))
        }
      />
    </label>
  )
}

export { ColorPicker, formatColorValue, parseColorValue }

组件作用

ColorPicker 默认提供面向普通用户的基础 RGB 模式,可直接打开系统色板或输入 R、G、B 数值。专业 OKLCH 模式将明度、色度和色相拆开控制,更适合精细配置设计 Token。

组件属性

PropTypeDefaultDescription
value *stringCSS OKLCH color value.
onValueChange *(value: string) => voidCalled continuously while a channel or exact value changes.
labelstring选择颜色Accessible name for the trigger.
showAlphabooleantrueShow the alpha channel control.
swatchesstring[][ "oklch(0.546 0.245 262.881)", // wui-token-audit-allow -- picker color data "oklch(0.585 0.233 277.117)", // wui-token-audit-allow -- picker color data "oklch(0.577 0.245 27.325)", // wui-token-audit-allow -- picker color data "oklch(0.666 0.179 58.318)", // wui-token-audit-allow -- picker color data "oklch(0.527 0.154 150.069)", // wui-token-audit-allow -- picker color data "oklch(0.6 0.118 184.704)", // wui-token-audit-allow -- picker color data ]Quick-pick OKLCH swatches.
disabledboolean

使用说明

组件接受受控的 valueonValueChange,RGB 与 OKLCH 会在切换模式时自动转换。通过 showAlpha 控制透明度通道,通过 swatches 提供产品自己的常用色板;弹层底部始终保留 CSS 原始值输入。