# Color Picker

An accessible, composable color picker with a saturation area, hue and alpha sliders, eyedropper, swatches, and hex/rgb/hsl/oklch output.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/color-picker.json
```

[Registry JSON](https://mwui.vercel.app/r/color-picker.json)

## Preview

```tsx
import * as React from "react";

import {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHue,
  ColorPickerInput,
  ColorPickerPreview,
  ColorPickerSwatch,
  ColorPickerSwatches,
} from "@/components/ui/color-picker";

const SWATCHES = [
  "#000000",
  "#64748b",
  "#ef4444",
  "#f97316",
  "#eab308",
  "#22c55e",
  "#06b6d4",
  "#3b82f6",
  "#8b5cf6",
  "#ec4899",
];

export function Preview() {
  const [color, setColor] = React.useState("#3b82f6");

  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <ColorPicker value={color} onValueChange={setColor} className="rounded-xl border p-3">
        <ColorPickerArea />

        <div className="flex items-center gap-3">
          <ColorPickerEyeDropper />
          <div className="flex min-w-0 flex-1 flex-col gap-2">
            <ColorPickerHue />
            <ColorPickerAlpha />
          </div>
          <ColorPickerPreview />
        </div>

        <ColorPickerFormatSelect className="w-full *:flex-1" />
        <ColorPickerInput alpha />

        <ColorPickerSwatches>
          {SWATCHES.map((swatch) => (
            <ColorPickerSwatch key={swatch} value={swatch} />
          ))}
        </ColorPickerSwatches>
      </ColorPicker>

      <p className="text-center font-mono text-xs text-muted-foreground">{color}</p>
    </div>
  );
}
```


## Source

### ui/color-picker.tsx

```tsx
"use client";

import { IconCheck, IconColorPicker } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";

type ColorFormat = "hex" | "rgb" | "hsl" | "oklch";

type Hsva = { h: number; s: number; v: number; a: number };

type Rgb = { r: number; g: number; b: number };

type Oklch = { l: number; c: number; h: number };

const BLACK: Hsva = { h: 0, s: 0, v: 0, a: 1 };

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

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

function round(value: number, precision = 0) {
  const factor = 10 ** precision;
  return Math.round(value * factor) / factor;
}

function hsvaToRgb({ h, s, v }: Hsva): Rgb {
  const hue = normalizeHue(h);
  const chroma = v * s;
  const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
  const m = v - chroma;
  const [r, g, b] =
    hue < 60
      ? [chroma, x, 0]
      : hue < 120
        ? [x, chroma, 0]
        : hue < 180
          ? [0, chroma, x]
          : hue < 240
            ? [0, x, chroma]
            : hue < 300
              ? [x, 0, chroma]
              : [chroma, 0, x];

  return {
    r: Math.round((r + m) * 255),
    g: Math.round((g + m) * 255),
    b: Math.round((b + m) * 255),
  };
}

function rgbToHsv({ r, g, b }: Rgb): Omit<Hsva, "a"> {
  const red = r / 255;
  const green = g / 255;
  const blue = b / 255;
  const max = Math.max(red, green, blue);
  const min = Math.min(red, green, blue);
  const delta = max - min;

  let h = 0;
  if (delta !== 0) {
    if (max === red) h = ((green - blue) / delta + 6) % 6;
    else if (max === green) h = (blue - red) / delta + 2;
    else h = (red - green) / delta + 4;
    h *= 60;
  }

  return { h, s: max === 0 ? 0 : delta / max, v: max };
}

function hsvToHsl({ h, s, v }: Omit<Hsva, "a">) {
  const l = v * (1 - s / 2);
  const saturation = l === 0 || l === 1 ? 0 : (v - l) / Math.min(l, 1 - l);
  return { h, s: saturation, l };
}

function hslToHsv({ h, s, l }: { h: number; s: number; l: number }) {
  const v = l + s * Math.min(l, 1 - l);
  return { h, s: v === 0 ? 0 : 2 * (1 - l / v), v };
}

const SRGB_LINEAR_THRESHOLD = 0.04045;

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

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

function rgbToOklch({ r, g, b }: Rgb): Oklch {
  const red = srgbToLinear(r / 255);
  const green = srgbToLinear(g / 255);
  const blue = srgbToLinear(b / 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 bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;

  return {
    l: lightness,
    c: Math.sqrt(a * a + bb * bb),
    h: normalizeHue((Math.atan2(bb, a) * 180) / Math.PI),
  };
}

function oklchToRgb({ l, c, h }: Oklch): Rgb {
  const hueRadians = (normalizeHue(h) * Math.PI) / 180;
  const a = c * Math.cos(hueRadians);
  const b = c * Math.sin(hueRadians);

  const lCubed = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
  const mCubed = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
  const sCubed = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;

  const red = 4.0767416621 * lCubed - 3.3077115913 * mCubed + 0.2309699292 * sCubed;
  const green = -1.2684380046 * lCubed + 2.6097574011 * mCubed - 0.3413193965 * sCubed;
  const blue = -0.0041960863 * lCubed - 0.7034186147 * mCubed + 1.707614701 * sCubed;

  return {
    r: Math.round(clamp(linearToSrgb(red)) * 255),
    g: Math.round(clamp(linearToSrgb(green)) * 255),
    b: Math.round(clamp(linearToSrgb(blue)) * 255),
  };
}

function expandShorthandHex(hex: string) {
  return hex
    .split("")
    .map((character) => character + character)
    .join("");
}

function parseHex(input: string): Hsva | null {
  const hex = input.replace(/^#/, "").toLowerCase();
  if (!/^[0-9a-f]+$/.test(hex)) return null;

  const expanded = hex.length === 3 || hex.length === 4 ? expandShorthandHex(hex) : hex;
  if (expanded.length !== 6 && expanded.length !== 8) return null;

  const rgb = {
    r: Number.parseInt(expanded.slice(0, 2), 16),
    g: Number.parseInt(expanded.slice(2, 4), 16),
    b: Number.parseInt(expanded.slice(4, 6), 16),
  };
  const alpha = expanded.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1;

  return { ...rgbToHsv(rgb), a: alpha };
}

function parseFunctionalNotation(input: string) {
  const match = /^([a-z]+)\(([^)]*)\)$/.exec(input);
  if (!match) return null;

  const [, name, body] = match;
  const [values, alphaPart] = body.split("/");
  const args = values
    .trim()
    .split(/[\s,]+/)
    .filter(Boolean);
  const alphaArgs =
    alphaPart
      ?.trim()
      .split(/[\s,]+/)
      .filter(Boolean) ?? [];

  const alphaToken = alphaArgs[0] ?? (args.length > 3 ? args[3] : undefined);

  return { name, args, alphaToken };
}

function readNumber(token: string | undefined, scaleIfPercent: number) {
  if (token === undefined) return null;
  const value = Number.parseFloat(token);
  if (Number.isNaN(value)) return null;
  return token.endsWith("%") ? (value / 100) * scaleIfPercent : value;
}

function readAlpha(token: string | undefined) {
  const alpha = readNumber(token, 1);
  return alpha === null ? 1 : clamp(alpha);
}

function parseColor(input: string): Hsva | null {
  const value = input.trim().toLowerCase();
  if (!value) return null;
  if (value === "transparent") return { ...BLACK, a: 0 };
  if (value.startsWith("#") || /^[0-9a-f]{3,8}$/.test(value)) return parseHex(value);

  const parsed = parseFunctionalNotation(value);
  if (!parsed) return null;

  const { name, args, alphaToken } = parsed;
  const alpha = readAlpha(alphaToken);

  if (name === "rgb" || name === "rgba") {
    const r = readNumber(args[0], 255);
    const g = readNumber(args[1], 255);
    const b = readNumber(args[2], 255);
    if (r === null || g === null || b === null) return null;
    return {
      ...rgbToHsv({ r: clamp(r, 0, 255), g: clamp(g, 0, 255), b: clamp(b, 0, 255) }),
      a: alpha,
    };
  }

  if (name === "hsl" || name === "hsla") {
    const h = readNumber(args[0], 360);
    const s = readNumber(args[1], 1);
    const l = readNumber(args[2], 1);
    if (h === null || s === null || l === null) return null;
    const saturation = args[1]?.endsWith("%") ? s : s / 100;
    const lightness = args[2]?.endsWith("%") ? l : l / 100;
    return { ...hslToHsv({ h, s: clamp(saturation), l: clamp(lightness) }), a: alpha };
  }

  if (name === "oklch") {
    const l = readNumber(args[0], 1);
    const c = readNumber(args[1], 0.4);
    const h = readNumber(args[2], 360);
    if (l === null || c === null || h === null) return null;
    return { ...rgbToHsv(oklchToRgb({ l: clamp(l), c: Math.max(0, c), h })), a: alpha };
  }

  return null;
}

function toHex(hsva: Hsva, { alpha = true }: { alpha?: boolean } = {}) {
  const { r, g, b } = hsvaToRgb(hsva);
  const channels = [r, g, b];
  if (alpha && hsva.a < 1) channels.push(Math.round(hsva.a * 255));
  return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
}

function formatColor(hsva: Hsva, format: ColorFormat): string {
  const alpha = round(hsva.a, 3);
  const alphaSuffix = alpha < 1 ? ` / ${alpha}` : "";

  if (format === "hex") return toHex(hsva);

  if (format === "rgb") {
    const { r, g, b } = hsvaToRgb(hsva);
    return `rgb(${r} ${g} ${b}${alphaSuffix})`;
  }

  if (format === "hsl") {
    const { h, s, l } = hsvToHsl(hsva);
    return `hsl(${round(normalizeHue(h), 2)} ${round(s * 100, 2)}% ${round(l * 100, 2)}%${alphaSuffix})`;
  }

  const { l, c, h } = rgbToOklch(hsvaToRgb(hsva));
  return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)}${alphaSuffix})`;
}

const HUE_ANCHORS: [angle: number, name: string][] = [
  [29, "red"],
  [55, "orange"],
  [85, "orange yellow"],
  [110, "yellow"],
  [128, "yellow green"],
  [142, "green"],
  [170, "green cyan"],
  [195, "cyan"],
  [230, "blue cyan"],
  [264, "blue"],
  [290, "blue purple"],
  [310, "purple"],
  [328, "magenta"],
  [350, "pink"],
];

function nameHue(oklchHue: number) {
  let closest = HUE_ANCHORS[0];
  let smallestDistance = Number.POSITIVE_INFINITY;

  for (const anchor of HUE_ANCHORS) {
    const difference = Math.abs(normalizeHue(oklchHue - anchor[0]));
    const distance = Math.min(difference, 360 - difference);
    if (distance < smallestDistance) {
      smallestDistance = distance;
      closest = anchor;
    }
  }

  return closest[1];
}

function describeColor(hsva: Hsva): string {
  if (hsva.a === 0) return "transparent";

  const { l, c, h } = rgbToOklch(hsvaToRgb(hsva));
  if (l < 0.08) return "black";
  if (l > 0.97 && c < 0.02) return "white";

  const lightness =
    l < 0.3
      ? "very dark "
      : l < 0.45
        ? "dark "
        : l < 0.72
          ? ""
          : l < 0.88
            ? "light "
            : "very light ";

  if (c < 0.025) return `${lightness}gray`.trim();

  const chroma = c < 0.06 ? "grayish " : c < 0.12 ? "muted " : c > 0.2 ? "vibrant " : "";
  const hue = nameHue(h);

  if (l < 0.55 && (hue === "orange" || hue === "orange yellow")) {
    return `${lightness}${chroma}brown`.trim();
  }

  return `${lightness}${chroma}${hue}`.trim();
}

const CHECKERBOARD_STYLE: React.CSSProperties = {
  backgroundColor: "#fff",
  backgroundImage:
    "linear-gradient(45deg, rgb(0 0 0 / 0.12) 25%, transparent 25% 75%, rgb(0 0 0 / 0.12) 75%), linear-gradient(45deg, rgb(0 0 0 / 0.12) 25%, transparent 25% 75%, rgb(0 0 0 / 0.12) 75%)",
  backgroundSize: "8px 8px",
  backgroundPosition: "0 0, 4px 4px",
};

const HUE_GRADIENT =
  "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%))";

const thumbClassName =
  "pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.35),0_1px_2px_rgb(0_0_0/0.25)]";

const trackClassName =
  "relative h-3 w-full touch-none rounded-full outline-none select-none has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50 data-disabled:pointer-events-none data-disabled:opacity-50";

const srOnlyClassName =
  "absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap border-0 p-0 m-0";

type ColorPickerContextValue = {
  hsva: Hsva;
  setColor: (next: Hsva | ((previous: Hsva) => Hsva)) => void;
  rgb: Rgb;
  hex: string;
  cssColor: string;
  value: string;
  format: ColorFormat;
  setFormat: (format: ColorFormat) => void;
  description: string;
  disabled: boolean;
};

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

function useColorPicker() {
  const context = React.useContext(ColorPickerContext);
  if (!context) {
    throw new Error("useColorPicker must be used within a <ColorPicker />.");
  }
  return context;
}

type ColorPickerProps = Omit<React.ComponentProps<"div">, "color" | "defaultValue" | "onChange"> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  format?: ColorFormat;
  defaultFormat?: ColorFormat;
  onFormatChange?: (format: ColorFormat) => void;
  disabled?: boolean;
  name?: string;
};

function ColorPicker({
  value,
  defaultValue = "#000000",
  onValueChange,
  format,
  defaultFormat = "hex",
  onFormatChange,
  disabled = false,
  name,
  className,
  children,
  ...props
}: ColorPickerProps) {
  const [uncontrolledFormat, setUncontrolledFormat] = React.useState(defaultFormat);
  const activeFormat = format ?? uncontrolledFormat;

  const [uncontrolledColor, setUncontrolledColor] = React.useState(
    () => parseColor(defaultValue) ?? BLACK,
  );

  const emitted = React.useRef<{ text: string; hsva: Hsva } | null>(null);

  const isControlled = value !== undefined;
  const hsva = React.useMemo(() => {
    if (!isControlled) return uncontrolledColor;
    if (emitted.current?.text === value) return emitted.current.hsva;
    return parseColor(value) ?? BLACK;
  }, [isControlled, value, uncontrolledColor]);

  const emit = React.useCallback(
    (next: Hsva, nextFormat: ColorFormat) => {
      const text = formatColor(next, nextFormat);
      emitted.current = { text, hsva: next };
      if (!isControlled) setUncontrolledColor(next);
      onValueChange?.(text);
      return text;
    },
    [isControlled, onValueChange],
  );

  const setColor = React.useCallback<ColorPickerContextValue["setColor"]>(
    (next) => {
      if (disabled) return;
      emit(typeof next === "function" ? next(hsva) : next, activeFormat);
    },
    [disabled, emit, hsva, activeFormat],
  );

  const setFormat = React.useCallback(
    (nextFormat: ColorFormat) => {
      if (disabled || nextFormat === activeFormat) return;
      if (format === undefined) setUncontrolledFormat(nextFormat);
      onFormatChange?.(nextFormat);
      emit(hsva, nextFormat);
    },
    [disabled, activeFormat, format, onFormatChange, emit, hsva],
  );

  const contextValue = React.useMemo<ColorPickerContextValue>(() => {
    const rgb = hsvaToRgb(hsva);
    return {
      hsva,
      setColor,
      rgb,
      hex: toHex(hsva, { alpha: false }),
      cssColor: `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${round(hsva.a, 3)})`,
      value: formatColor(hsva, activeFormat),
      format: activeFormat,
      setFormat,
      description: describeColor(hsva),
      disabled,
    };
  }, [hsva, setColor, activeFormat, setFormat, disabled]);

  return (
    <ColorPickerContext.Provider value={contextValue}>
      <div
        data-slot="color-picker"
        data-disabled={disabled || undefined}
        className={cn("flex w-full flex-col gap-3", className)}
        {...props}
      >
        {children}
        {name ? <input type="hidden" name={name} value={contextValue.value} /> : null}
      </div>
    </ColorPickerContext.Provider>
  );
}

const AREA_STEP = 0.01;
const AREA_LARGE_STEP = 0.1;

function ColorPickerArea({
  className,
  style,
  ...props
}: Omit<React.ComponentProps<"div">, "onChange">) {
  const { hsva, setColor, disabled, description } = useColorPicker();
  const areaRef = React.useRef<HTMLDivElement>(null);

  const commitFromPointer = (clientX: number, clientY: number) => {
    const area = areaRef.current;
    if (!area) return;
    const rect = area.getBoundingClientRect();
    setColor((previous) => ({
      ...previous,
      s: clamp((clientX - rect.left) / rect.width),
      v: 1 - clamp((clientY - rect.top) / rect.height),
    }));
  };

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || event.button !== 0) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    commitFromPointer(event.clientX, event.clientY);
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
    commitFromPointer(event.clientX, event.clientY);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    const step = event.shiftKey ? AREA_LARGE_STEP : AREA_STEP;
    const nudge = (axis: "s" | "v", delta: number) => {
      event.preventDefault();
      setColor((previous) => ({ ...previous, [axis]: clamp(previous[axis] + delta) }));
    };

    switch (event.key) {
      case "ArrowLeft":
        return nudge("s", -step);
      case "ArrowRight":
        return nudge("s", step);
      case "ArrowUp":
        return nudge("v", step);
      case "ArrowDown":
        return nudge("v", -step);
      case "PageUp":
        return nudge("v", AREA_LARGE_STEP);
      case "PageDown":
        return nudge("v", -AREA_LARGE_STEP);
      case "Home":
        event.preventDefault();
        return setColor((previous) => ({ ...previous, s: 0 }));
      case "End":
        event.preventDefault();
        return setColor((previous) => ({ ...previous, s: 1 }));
      default:
    }
  };

  const saturation = Math.round(hsva.s * 100);
  const brightness = Math.round(hsva.v * 100);

  const axisInputProps = {
    min: 0,
    max: 100,
    step: 1,
    disabled,
    className: srOnlyClassName,
    onKeyDown: handleKeyDown,
  };

  return (
    <div
      ref={areaRef}
      data-slot="color-picker-area"
      data-disabled={disabled || undefined}
      role="group"
      aria-label="Saturation and brightness"
      className={cn(
        "relative aspect-4/3 w-full touch-none rounded-lg outline-none select-none",
        "has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
        !disabled && "cursor-crosshair",
        className,
      )}
      style={{
        backgroundColor: `hsl(${round(normalizeHue(hsva.h), 2)} 100% 50%)`,
        backgroundImage:
          "linear-gradient(to top, rgb(0 0 0) 0%, rgb(0 0 0 / 0) 100%), linear-gradient(to right, rgb(255 255 255) 0%, rgb(255 255 255 / 0) 100%)",
        ...style,
      }}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      {...props}
    >
      <input
        type="range"
        {...axisInputProps}
        aria-label="Saturation"
        aria-valuetext={`${saturation}%, ${description}`}
        value={saturation}
        onChange={(event) =>
          setColor((previous) => ({ ...previous, s: Number(event.target.value) / 100 }))
        }
      />
      <input
        type="range"
        {...axisInputProps}
        aria-label="Brightness"
        aria-valuetext={`${brightness}%, ${description}`}
        value={brightness}
        onChange={(event) =>
          setColor((previous) => ({ ...previous, v: Number(event.target.value) / 100 }))
        }
      />
      <div
        data-slot="color-picker-area-thumb"
        aria-hidden="true"
        className={thumbClassName}
        style={{
          left: `${hsva.s * 100}%`,
          top: `${(1 - hsva.v) * 100}%`,
          backgroundColor: toHex({ ...hsva, a: 1 }),
        }}
      />
    </div>
  );
}

type SliderTrackProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  slot: string;
  label: string;
  valueText: string;
  min: number;
  max: number;
  step: number;
  largeStep: number;
  value: number;
  onValueChange: (value: number) => void;
  trackStyle: React.CSSProperties;
  thumbColor: string;
};

function SliderTrack({
  slot,
  label,
  valueText,
  min,
  max,
  step,
  largeStep,
  value,
  onValueChange,
  trackStyle,
  thumbColor,
  className,
  style,
  ...props
}: SliderTrackProps) {
  const { disabled } = useColorPicker();
  const trackRef = React.useRef<HTMLDivElement>(null);

  const commitFromPointer = (clientX: number) => {
    const track = trackRef.current;
    if (!track) return;
    const rect = track.getBoundingClientRect();
    const ratio = clamp((clientX - rect.left) / rect.width);
    const raw = min + ratio * (max - min);
    onValueChange(clamp(Math.round(raw / step) * step, min, max));
  };

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || event.button !== 0) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    commitFromPointer(event.clientX);
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
    commitFromPointer(event.clientX);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (!event.shiftKey) return;
    const delta =
      event.key === "ArrowRight" || event.key === "ArrowUp"
        ? largeStep
        : event.key === "ArrowLeft" || event.key === "ArrowDown"
          ? -largeStep
          : 0;
    if (delta === 0) return;
    event.preventDefault();
    onValueChange(clamp(value + delta, min, max));
  };

  return (
    <div
      ref={trackRef}
      data-slot={slot}
      data-disabled={disabled || undefined}
      className={cn(trackClassName, !disabled && "cursor-pointer", className)}
      style={{ ...trackStyle, ...style }}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      {...props}
    >
      <input
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        disabled={disabled}
        aria-label={label}
        aria-valuetext={valueText}
        className={srOnlyClassName}
        onChange={(event) => onValueChange(Number(event.target.value))}
        onKeyDown={handleKeyDown}
      />
      <div
        data-slot={`${slot}-thumb`}
        aria-hidden="true"
        className={cn(thumbClassName, "top-1/2")}
        style={{
          left: `${((value - min) / (max - min)) * 100}%`,
          backgroundColor: thumbColor,
        }}
      />
    </div>
  );
}

function ColorPickerHue(props: Omit<React.ComponentProps<"div">, "onChange">) {
  const { hsva, setColor, description } = useColorPicker();
  const hue = Math.round(normalizeHue(hsva.h));

  return (
    <SliderTrack
      slot="color-picker-hue"
      label="Hue"
      valueText={`${hue} degrees, ${description}`}
      min={0}
      max={360}
      step={1}
      largeStep={10}
      value={hue}
      onValueChange={(value) => setColor((previous) => ({ ...previous, h: value }))}
      trackStyle={{ backgroundImage: HUE_GRADIENT }}
      thumbColor={`hsl(${hue} 100% 50%)`}
      {...props}
    />
  );
}

function ColorPickerAlpha(props: Omit<React.ComponentProps<"div">, "onChange">) {
  const { hsva, setColor, rgb, description } = useColorPicker();
  const alpha = Math.round(hsva.a * 100);

  return (
    <SliderTrack
      slot="color-picker-alpha"
      label="Alpha"
      valueText={`${alpha}%, ${description}`}
      min={0}
      max={100}
      step={1}
      largeStep={10}
      value={alpha}
      onValueChange={(value) => setColor((previous) => ({ ...previous, a: value / 100 }))}
      trackStyle={{
        ...CHECKERBOARD_STYLE,
        backgroundImage: `linear-gradient(to right, rgb(${rgb.r} ${rgb.g} ${rgb.b} / 0), rgb(${rgb.r} ${rgb.g} ${rgb.b})), ${CHECKERBOARD_STYLE.backgroundImage}`,
        backgroundSize: `auto, ${CHECKERBOARD_STYLE.backgroundSize}`,
        backgroundPosition: `0 0, ${CHECKERBOARD_STYLE.backgroundPosition}`,
      }}
      thumbColor={`rgb(${rgb.r} ${rgb.g} ${rgb.b})`}
      {...props}
    />
  );
}

function ColorPickerPreview({ className, style, ...props }: React.ComponentProps<"div">) {
  const { cssColor, description, value } = useColorPicker();

  return (
    <div
      data-slot="color-picker-preview"
      role="img"
      aria-label={`${description}, ${value}`}
      className={cn(
        "relative size-8 shrink-0 overflow-hidden rounded-lg border border-border",
        className,
      )}
      style={{ ...CHECKERBOARD_STYLE, ...style }}
      {...props}
    >
      <div className="absolute inset-0" style={{ backgroundColor: cssColor }} />
    </div>
  );
}

type EyeDropperInstance = {
  open: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
};

declare global {
  interface Window {
    EyeDropper?: new () => EyeDropperInstance;
  }
}

function ColorPickerEyeDropper({
  className,
  onClick,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { setColor, disabled } = useColorPicker();
  const [isSupported, setIsSupported] = React.useState(false);

  React.useEffect(() => {
    setIsSupported(typeof window !== "undefined" && "EyeDropper" in window);
  }, []);

  if (!isSupported) return null;

  const pick = async () => {
    const EyeDropperConstructor = window.EyeDropper;
    if (!EyeDropperConstructor) return;
    const result = await new EyeDropperConstructor().open().catch(() => null);
    if (!result) return;
    const picked = parseColor(result.sRGBHex);
    if (picked) setColor((previous) => ({ ...picked, a: previous.a }));
  };

  return (
    <Button
      type="button"
      variant="outline"
      size="icon"
      data-slot="color-picker-eye-dropper"
      aria-label="Pick a color from the screen"
      disabled={disabled}
      className={className}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) void pick();
      }}
      {...props}
    >
      <IconColorPicker />
    </Button>
  );
}

const ALL_FORMATS: ColorFormat[] = ["hex", "rgb", "hsl", "oklch"];

function ColorPickerFormatSelect({
  formats = ALL_FORMATS,
  className,
  ...props
}: React.ComponentProps<typeof ButtonGroup> & { formats?: ColorFormat[] }) {
  const { format, setFormat, disabled } = useColorPicker();

  return (
    <ButtonGroup
      data-slot="color-picker-format-select"
      aria-label="Color format"
      className={className}
      {...props}
    >
      {formats.map((option) => (
        <Button
          key={option}
          type="button"
          size="xs"
          variant="outline"
          aria-pressed={format === option}
          disabled={disabled}
          className="uppercase aria-pressed:bg-muted aria-pressed:text-foreground"
          onClick={() => setFormat(option)}
        >
          {option}
        </Button>
      ))}
    </ButtonGroup>
  );
}

type ChannelKey = "r" | "g" | "b" | "h" | "s" | "l" | "c" | "a";

type ChannelConfig = {
  key: ChannelKey;
  label: string;
  name: string;
  min: number;
  max: number;
  step: number;
  precision: number;
};

const CHANNELS: Record<Exclude<ColorFormat, "hex">, ChannelConfig[]> = {
  rgb: [
    { key: "r", label: "R", name: "Red", min: 0, max: 255, step: 1, precision: 0 },
    { key: "g", label: "G", name: "Green", min: 0, max: 255, step: 1, precision: 0 },
    { key: "b", label: "B", name: "Blue", min: 0, max: 255, step: 1, precision: 0 },
  ],
  hsl: [
    { key: "h", label: "H", name: "Hue", min: 0, max: 360, step: 1, precision: 0 },
    { key: "s", label: "S", name: "Saturation", min: 0, max: 100, step: 1, precision: 0 },
    { key: "l", label: "L", name: "Lightness", min: 0, max: 100, step: 1, precision: 0 },
  ],
  oklch: [
    { key: "l", label: "L", name: "Lightness", min: 0, max: 1, step: 0.001, precision: 3 },
    { key: "c", label: "C", name: "Chroma", min: 0, max: 0.4, step: 0.001, precision: 3 },
    { key: "h", label: "H", name: "Hue", min: 0, max: 360, step: 0.1, precision: 1 },
  ],
};

const ALPHA_CHANNEL: ChannelConfig = {
  key: "a",
  label: "A",
  name: "Alpha",
  min: 0,
  max: 100,
  step: 1,
  precision: 0,
};

function readChannels(hsva: Hsva, format: Exclude<ColorFormat, "hex">): Record<string, number> {
  if (format === "rgb") return hsvaToRgb(hsva);
  if (format === "hsl") {
    const { h, s, l } = hsvToHsl(hsva);
    return { h: normalizeHue(h), s: s * 100, l: l * 100 };
  }
  return rgbToOklch(hsvaToRgb(hsva));
}

function writeChannels(
  channels: Record<string, number>,
  format: Exclude<ColorFormat, "hex">,
  alpha: number,
): Hsva {
  if (format === "rgb") {
    return { ...rgbToHsv({ r: channels.r, g: channels.g, b: channels.b }), a: alpha };
  }
  if (format === "hsl") {
    return {
      ...hslToHsv({ h: channels.h, s: channels.s / 100, l: channels.l / 100 }),
      a: alpha,
    };
  }
  return {
    ...rgbToHsv(oklchToRgb({ l: channels.l, c: channels.c, h: channels.h })),
    a: alpha,
  };
}

function ChannelField({
  config,
  value,
  onCommit,
}: {
  config: ChannelConfig;
  value: number;
  onCommit: (value: number) => void;
}) {
  const { disabled } = useColorPicker();
  const [draft, setDraft] = React.useState<string | null>(null);
  const displayed = draft ?? String(round(value, config.precision));

  return (
    <InputGroup className="min-w-0">
      <InputGroupAddon>
        <span className="text-xs text-muted-foreground select-none">{config.label}</span>
      </InputGroupAddon>
      <InputGroupInput
        type="number"
        inputMode="decimal"
        min={config.min}
        max={config.max}
        step={config.step}
        value={displayed}
        disabled={disabled}
        aria-label={config.name}
        className="[appearance:textfield] pl-0! tabular-nums [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
        onChange={(event) => {
          setDraft(event.target.value);
          const parsed = Number.parseFloat(event.target.value);
          if (!Number.isNaN(parsed)) {
            onCommit(clamp(parsed, config.min, config.max));
          }
        }}
        onBlur={() => setDraft(null)}
      />
    </InputGroup>
  );
}

function HexField() {
  const { hsva, setColor, disabled } = useColorPicker();
  const [draft, setDraft] = React.useState<string | null>(null);
  const displayed = draft ?? toHex(hsva).replace(/^#/, "");

  return (
    <InputGroup className="min-w-0">
      <InputGroupAddon>
        <span className="text-xs text-muted-foreground select-none">#</span>
      </InputGroupAddon>
      <InputGroupInput
        value={displayed}
        disabled={disabled}
        aria-label="Hex color"
        spellCheck={false}
        autoComplete="off"
        autoCapitalize="none"
        maxLength={8}
        className="pl-0! font-mono uppercase"
        onChange={(event) => {
          const sanitized = event.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 8);
          setDraft(sanitized);
          const parsed = parseColor(sanitized);
          if (parsed) setColor(parsed);
        }}
        onBlur={() => setDraft(null)}
      />
    </InputGroup>
  );
}

function ColorPickerInput({
  alpha = false,
  className,
  ...props
}: React.ComponentProps<"div"> & {
  alpha?: boolean;
}) {
  const { hsva, setColor, format } = useColorPicker();

  const channels = format === "hex" ? [] : CHANNELS[format];
  const values = format === "hex" ? {} : readChannels(hsva, format);

  return (
    <div
      data-slot="color-picker-input"
      className={cn("grid auto-cols-fr grid-flow-col gap-2", className)}
      {...props}
    >
      {format === "hex" ? (
        <HexField />
      ) : (
        channels.map((config) => (
          <ChannelField
            key={config.key}
            config={config}
            value={values[config.key]}
            onCommit={(next) =>
              setColor((previous) =>
                writeChannels(
                  { ...readChannels(previous, format), [config.key]: next },
                  format,
                  previous.a,
                ),
              )
            }
          />
        ))
      )}
      {alpha ? (
        <ChannelField
          config={ALPHA_CHANNEL}
          value={hsva.a * 100}
          onCommit={(next) => setColor((previous) => ({ ...previous, a: next / 100 }))}
        />
      ) : null}
    </div>
  );
}

function ColorPickerSwatches({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="color-picker-swatches"
      role="group"
      aria-label="Color presets"
      className={cn("flex flex-wrap gap-1.5", className)}
      {...props}
    />
  );
}

function ColorPickerSwatch({
  value,
  className,
  style,
  onClick,
  ...props
}: Omit<React.ComponentProps<"button">, "value"> & { value: string }) {
  const { hsva, setColor, disabled } = useColorPicker();

  const swatch = parseColor(value);
  const isSelected = swatch ? toHex(swatch) === toHex(hsva) : false;
  const checkColor = swatch && rgbToOklch(hsvaToRgb(swatch)).l < 0.6 ? "#fff" : "#000";

  return (
    <button
      type="button"
      data-slot="color-picker-swatch"
      data-selected={isSelected || undefined}
      aria-pressed={isSelected}
      aria-label={swatch ? `${describeColor(swatch)}, ${value}` : value}
      disabled={disabled}
      className={cn(
        "relative flex size-6 items-center justify-center rounded-md border border-border/60 transition-[box-shadow,transform] outline-none",
        "hover:scale-110 focus-visible:ring-3 focus-visible:ring-ring/50",
        "disabled:pointer-events-none disabled:opacity-50",
        className,
      )}
      style={{ backgroundColor: value, ...style }}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented && swatch) setColor(swatch);
      }}
      {...props}
    >
      {isSelected ? <IconCheck className="size-3.5" style={{ color: checkColor }} /> : null}
    </button>
  );
}

export {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHue,
  ColorPickerInput,
  ColorPickerPreview,
  ColorPickerSwatch,
  ColorPickerSwatches,
  describeColor,
  formatColor,
  parseColor,
  useColorPicker,
  type ColorFormat,
  type ColorPickerProps,
  type Hsva,
};
```



## Usage

A headless-ish set of parts rather than one fixed widget: `ColorPicker` owns the color state and
every other part reads from it, so you arrange the surface you actually need. Nothing renders unless
you ask for it — drop `ColorPickerAlpha` if your colors are always opaque, drop
`ColorPickerFormatSelect` if you only ever emit hex.

```tsx
import {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHue,
  ColorPickerInput,
  ColorPickerPreview,
  ColorPickerSwatch,
  ColorPickerSwatches,
} from "@/components/ui/color-picker";

export function BrandColor() {
  const [color, setColor] = useState("#3b82f6");

  return (
    <ColorPicker value={color} onValueChange={setColor}>
      <ColorPickerArea />
      <div className="flex items-center gap-3">
        <ColorPickerEyeDropper />
        <div className="flex min-w-0 flex-1 flex-col gap-2">
          <ColorPickerHue />
          <ColorPickerAlpha />
        </div>
        <ColorPickerPreview />
      </div>
      <ColorPickerFormatSelect />
      <ColorPickerInput alpha />
      <ColorPickerSwatches>
        <ColorPickerSwatch value="#ef4444" />
        <ColorPickerSwatch value="#22c55e" />
        <ColorPickerSwatch value="#3b82f6" />
      </ColorPickerSwatches>
    </ColorPicker>
  );
}
```

## State

`ColorPicker` is controlled with `value` / `onValueChange` and uncontrolled with `defaultValue`.
`onValueChange` hands you a CSS color string serialized in the active format, so the value you get
back is the value you can paste straight into a stylesheet.

```tsx
<ColorPicker defaultValue="oklch(0.62 0.19 259)" defaultFormat="oklch" />
```

Internally the color is stored as HSVA, not as the string. That is what lets you drag into pure
black and drag back out onto the hue you started from — a picker that round-trips through hex loses
the hue the moment brightness hits zero.

`format` / `defaultFormat` / `onFormatChange` control the output format the same way. Switching
format fires `onValueChange` too, since the value string changes with it.

Pass `name` to submit the value with a plain HTML form:

```tsx
<ColorPicker name="accent" defaultValue="#3b82f6" />
```

## Parts

| Part                      | Notes                                                                       |
| ------------------------- | --------------------------------------------------------------------------- |
| `ColorPicker`             | State owner and context provider. Accepts `disabled` and `name`.             |
| `ColorPickerArea`         | Saturation and brightness plane.                                             |
| `ColorPickerHue`          | Hue slider, 0–360.                                                           |
| `ColorPickerAlpha`        | Alpha slider over a checkerboard.                                            |
| `ColorPickerPreview`      | Current color chip. Size it with `className`.                                |
| `ColorPickerEyeDropper`   | Screen sampler. Renders nothing where the EyeDropper API is unavailable.     |
| `ColorPickerFormatSelect` | Segmented format control. Narrow it with `formats={["hex", "rgb"]}`.         |
| `ColorPickerInput`        | Format-aware fields — a hex box, or one field per channel. `alpha` adds one. |
| `ColorPickerSwatches`     | Preset container.                                                            |
| `ColorPickerSwatch`       | One preset. `value` takes any parseable color string.                        |

## Popover trigger

The item ships the panel, not the trigger, so it composes with whatever overlay you already use:

```tsx
<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>
    <span className="size-4 rounded" style={{ backgroundColor: color }} />
    {color}
  </PopoverTrigger>
  <PopoverContent className="w-64">
    <ColorPicker value={color} onValueChange={setColor}>
      <ColorPickerArea />
      <ColorPickerHue />
      <ColorPickerInput />
    </ColorPicker>
  </PopoverContent>
</Popover>
```

## Custom parts

`useColorPicker` exposes the same context the built-in parts use, so you can add your own without
forking the component:

```tsx
import { useColorPicker } from "@/components/ui/color-picker";

function CopyColor() {
  const { value, description } = useColorPicker();

  return (
    <Button variant="ghost" onClick={() => navigator.clipboard.writeText(value)}>
      Copy {description}
    </Button>
  );
}
```

It returns `hsva`, `setColor`, `rgb`, `hex`, `cssColor`, `value`, `format`, `setFormat`,
`description`, and `disabled`.

## Color utilities

`parseColor`, `formatColor`, and `describeColor` are exported for use outside the picker.
`parseColor` reads `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `hsl()`, and `oklch()` in both
the legacy comma syntax and the modern space/slash syntax, and returns `null` rather than throwing
on input it cannot read.

```tsx
formatColor(parseColor("#3b82f6")!, "oklch"); // "oklch(0.6231 0.188 259.81)"
describeColor(parseColor("#3b82f6")!); // "vibrant blue"
```

## Accessibility

The saturation plane is backed by two visually hidden range inputs — one per axis — following the
pattern React Aria uses for its `ColorArea`. Screen readers get a real value to announce, keyboard
users get a real control to focus, and mobile assistive tech gets its native slider gestures. Arrow
keys drive both axes from either input, so focus order never changes what the keys do.

Every control announces a plain-language color description next to its numeric value — "62%, dark
vibrant blue" rather than "182, 96, 38". Descriptions are derived in OKLCH, whose lightness is
perceptually even across hues, so "dark" means the same thing on yellow as it does on blue.

| Key                    | Action                                       |
| ---------------------- | -------------------------------------------- |
| `←` `→`                | Saturation, or the slider value, by one step |
| `↑` `↓`                | Brightness in the area                       |
| `Shift` + arrow        | Ten steps                                    |
| `Home` `End`           | Minimum / maximum                            |
| `Page Up` `Page Down`  | Ten steps                                    |

