Color Picker
Inputs & Forms

Color Picker

A full HSV color picker: a pointer-captured saturation/value plane with arrow-key control, hue and alpha rails over a checkerboard, a format-cycling hex/rgb/hsl field, a native eyedropper where available, and a recent-swatches row.

Install

npx shadcn@latest add @paragon/color-picker

color-picker.tsx

"use client";

import * as React from "react";
import { ChevronsUpDown, Pipette } from "lucide-react";
import { cn } from "@/lib/utils";

/* ----------------------------------------------------------------------
 * Color math. HSVA is the working space — it keeps hue stable while the
 * saturation/value plane is dragged through grays, where RGB round-trips
 * would snap the hue rail back to red.
 * ---------------------------------------------------------------------- */

export interface HsvaColor {
  /** Hue 0–360. */
  h: number;
  /** Saturation 0–100. */
  s: number;
  /** Value (brightness) 0–100. */
  v: number;
  /** Alpha 0–1. */
  a: number;
}

export interface RgbaColor {
  r: number;
  g: number;
  b: number;
  a: number;
}

const clamp = (v: number, min: number, max: number) =>
  Math.min(max, Math.max(min, v));

export function hsvaToRgba(color: HsvaColor): RgbaColor {
  const h = ((color.h % 360) + 360) % 360;
  const s = clamp(color.s, 0, 100) / 100;
  const v = clamp(color.v, 0, 100) / 100;
  const f = (n: number) => {
    const k = (n + h / 60) % 6;
    return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
  };
  return {
    r: Math.round(f(5) * 255),
    g: Math.round(f(3) * 255),
    b: Math.round(f(1) * 255),
    a: clamp(color.a, 0, 1),
  };
}

function rgbaToHsva({ r, g, b, a }: RgbaColor): HsvaColor {
  const rn = r / 255;
  const gn = g / 255;
  const bn = b / 255;
  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const d = max - min;
  let h = 0;
  if (d !== 0) {
    if (max === rn) h = ((gn - bn) / d) % 6;
    else if (max === gn) h = (bn - rn) / d + 2;
    else h = (rn - gn) / d + 4;
    h *= 60;
    if (h < 0) h += 360;
  }
  return {
    h,
    s: max === 0 ? 0 : (d / max) * 100,
    v: max * 100,
    a: clamp(a, 0, 1),
  };
}

function rgbaToHex({ r, g, b, a }: RgbaColor): string {
  const to = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
  const base = `#${to(r)}${to(g)}${to(b)}`;
  return a < 1 ? `${base}${to(a * 255)}` : base;
}

function hexToRgba(input: string): RgbaColor | null {
  const raw = input.trim().replace(/^#/, "");
  if (!/^[0-9a-f]+$/i.test(raw)) return null;
  if (![3, 4, 6, 8].includes(raw.length)) return null;
  const full =
    raw.length <= 4
      ? raw
          .split("")
          .map((c) => c + c)
          .join("")
      : raw;
  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
    a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1,
  };
}

/** HSL (CSS) → HSV, all channels 0–100 except hue. */
function hslToHsv(h: number, s: number, l: number): { h: number; s: number; v: number } {
  const sn = s / 100;
  const ln = l / 100;
  const v = ln + sn * Math.min(ln, 1 - ln);
  return { h, s: v === 0 ? 0 : 200 * (1 - ln / v), v: v * 100 };
}

/** HSV → HSL (CSS) for readouts. */
function hsvToHsl({ h, s, v }: { h: number; s: number; v: number }) {
  const sn = s / 100;
  const vn = v / 100;
  const l = vn * (1 - sn / 2);
  const sl = l === 0 || l === 1 ? 0 : (vn - l) / Math.min(l, 1 - l);
  return { h, s: sl * 100, l: l * 100 };
}

/**
 * Parse a CSS color string (hex, rgb/rgba, hsl/hsla) into HSVA.
 * Returns null when the string is not a recognizable color.
 */
export function parseColor(input: string): HsvaColor | null {
  const str = input.trim().toLowerCase();
  if (str.startsWith("#") || /^[0-9a-f]{3,8}$/.test(str)) {
    const rgba = hexToRgba(str);
    return rgba ? rgbaToHsva(rgba) : null;
  }
  const nums = str.match(/-?[\d.]+/g)?.map(Number) ?? [];
  if (str.startsWith("rgb") && nums.length >= 3) {
    const [r, g, b, a = 1] = nums;
    if ([r, g, b].some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
    return rgbaToHsva({ r, g, b, a: clamp(a, 0, 1) });
  }
  if (str.startsWith("hsl") && nums.length >= 3) {
    const [h, s, l, a = 1] = nums;
    if (Number.isNaN(h) || Number.isNaN(s) || Number.isNaN(l)) return null;
    const hsv = hslToHsv(((h % 360) + 360) % 360, clamp(s, 0, 100), clamp(l, 0, 100));
    return { ...hsv, a: clamp(a, 0, 1) };
  }
  return null;
}

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

const round2 = (n: number) => Math.round(n * 100) / 100;

/** Format an HSVA color as a CSS string in the given notation. */
export function formatHsva(color: HsvaColor, format: ColorFormat): string {
  const rgba = hsvaToRgba(color);
  if (format === "hex") return rgbaToHex(rgba);
  if (format === "rgb") {
    const { r, g, b, a } = rgba;
    return a < 1 ? `rgba(${r}, ${g}, ${b}, ${round2(a)})` : `rgb(${r}, ${g}, ${b})`;
  }
  const { h, s, l } = hsvToHsl(color);
  const hh = Math.round(h);
  const ss = Math.round(s);
  const ll = Math.round(l);
  return color.a < 1
    ? `hsla(${hh}, ${ss}%, ${ll}%, ${round2(color.a)})`
    : `hsl(${hh}, ${ss}%, ${ll}%)`;
}

/* ----------------------------------------------------------------------
 * Internals
 * ---------------------------------------------------------------------- */

/** Alpha checkerboard that reads correctly on both themes. */
const CHECKER: React.CSSProperties = {
  backgroundImage:
    "conic-gradient(rgba(128,128,128,0.4) 0 25%, transparent 0 50%, rgba(128,128,128,0.4) 0 75%, transparent 0)",
  backgroundSize: "8px 8px",
};

const HUE_GRADIENT =
  "linear-gradient(90deg, #f00 0%, #ff0 16.7%, #0f0 33.3%, #0ff 50%, #00f 66.7%, #f0f 83.3%, #f00 100%)";

const THUMB_CLASSES = cn(
  "pointer-events-none absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full",
  "border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.25),0_1px_2px_rgba(0,0,0,0.25)]",
  "transition-[scale] duration-100 ease-out",
);

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

/** A horizontal 0–max rail with a pointer-captured thumb + arrow keys. */
function Rail({
  label,
  value,
  max,
  step,
  bigStep,
  onChange,
  onCommit,
  disabled,
  trackStyle,
  trackChildren,
  thumbColor,
  valueText,
}: {
  label: string;
  value: number;
  max: number;
  step: number;
  bigStep: number;
  onChange: (next: number) => void;
  onCommit: () => void;
  disabled?: boolean;
  trackStyle?: React.CSSProperties;
  trackChildren?: React.ReactNode;
  thumbColor: string;
  valueText: string;
}) {
  const railRef = React.useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = React.useState(false);

  const fromPointer = (event: React.PointerEvent) => {
    const rail = railRef.current;
    if (!rail) return;
    const rect = rail.getBoundingClientRect();
    const t = clamp((event.clientX - rect.left) / rect.width, 0, 1);
    onChange(t * max);
  };

  return (
    <div
      ref={railRef}
      role="slider"
      tabIndex={disabled ? -1 : 0}
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-valuenow={Math.round(value * 100) / 100}
      aria-valuetext={valueText}
      aria-disabled={disabled || undefined}
      onPointerDown={(event) => {
        if (disabled || event.button !== 0) return;
        event.preventDefault();
        event.currentTarget.setPointerCapture(event.pointerId);
        event.currentTarget.focus();
        setDragging(true);
        fromPointer(event);
      }}
      onPointerMove={(event) => {
        if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
        fromPointer(event);
      }}
      onPointerUp={(event) => {
        if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
        event.currentTarget.releasePointerCapture(event.pointerId);
        setDragging(false);
        onCommit();
      }}
      onPointerCancel={(event) => {
        if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
        event.currentTarget.releasePointerCapture(event.pointerId);
        setDragging(false);
      }}
      onKeyDown={(event) => {
        if (disabled) return;
        const delta = event.shiftKey ? bigStep : step;
        if (event.key === "ArrowRight" || event.key === "ArrowUp") {
          event.preventDefault();
          onChange(clamp(value + delta, 0, max));
          onCommit();
        } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
          event.preventDefault();
          onChange(clamp(value - delta, 0, max));
          onCommit();
        } else if (event.key === "Home") {
          event.preventDefault();
          onChange(0);
          onCommit();
        } else if (event.key === "End") {
          event.preventDefault();
          onChange(max);
          onCommit();
        }
      }}
      className={cn(
        "relative h-3 w-full cursor-pointer touch-none rounded-full outline-none select-none",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        // Hit area ≥ 40px tall without growing the visual rail.
        "after:absolute after:top-1/2 after:left-0 after:h-10 after:w-full after:-translate-y-1/2",
      )}
    >
      <span
        aria-hidden
        className="absolute inset-0 overflow-hidden rounded-full shadow-[inset_0_0_0_1px_rgba(128,128,128,0.25)]"
        style={trackStyle}
      >
        {trackChildren}
      </span>
      <span
        aria-hidden
        className={cn(THUMB_CLASSES, dragging && "scale-110")}
        style={{
          left: `${(value / max) * 100}%`,
          backgroundColor: thumbColor,
        }}
      />
    </div>
  );
}

/* ----------------------------------------------------------------------
 * ColorPicker
 * ---------------------------------------------------------------------- */

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

export interface ColorPickerProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** Controlled color as any CSS color string (hex, rgb, hsl). */
  value?: string;
  /** Initial color when uncontrolled. */
  defaultValue?: string;
  /**
   * Fired on every change with the color as hex (hex8 when translucent)
   * plus the raw HSVA object.
   */
  onValueChange?: (color: string, hsva: HsvaColor) => void;
  /** Show the alpha rail. When false the alpha channel is locked to 1. */
  showAlpha?: boolean;
  /** Show the eyedropper button where the EyeDropper API is available. */
  showEyeDropper?: boolean;
  /** Seed colors for the swatch row; recent picks are added in front. */
  swatches?: string[];
  /** Popover-embeddable footprints. */
  size?: "sm" | "md";
  /** Form field name; renders a hidden input with the hex value. */
  name?: string;
  disabled?: boolean;
}

/**
 * A full HSV color picker: a pointer-captured saturation/value plane with
 * arrow-key control, hue and alpha rails over a checkerboard, a format-
 * cycling hex/rgb/hsl field, a native eyedropper where the API exists,
 * and a recent-swatches row that fills as colors are committed.
 */
export function ColorPicker({
  value: valueProp,
  defaultValue = "#4D80E6",
  onValueChange,
  showAlpha = true,
  showEyeDropper = true,
  swatches,
  size = "md",
  name,
  disabled = false,
  className,
  ...props
}: ColorPickerProps) {
  const [hsva, setHsva] = React.useState<HsvaColor>(
    () => parseColor(valueProp ?? defaultValue) ?? { h: 239, s: 61, v: 95, a: 1 },
  );
  const lastEmitted = React.useRef<string | null>(null);

  // Controlled sync: re-parse only when the prop is a color we didn't emit.
  React.useEffect(() => {
    if (valueProp === undefined || valueProp === lastEmitted.current) return;
    const parsed = parseColor(valueProp);
    if (parsed) setHsva(showAlpha ? parsed : { ...parsed, a: 1 });
  }, [valueProp, showAlpha]);

  const [format, setFormat] = React.useState<ColorFormat>("hex");
  const [draft, setDraft] = React.useState<string | null>(null);
  const [planeDragging, setPlaneDragging] = React.useState(false);
  const [recent, setRecent] = React.useState<string[]>(() =>
    (swatches ?? []).slice(0, 8),
  );
  const [canEyeDrop, setCanEyeDrop] = React.useState(false);
  const planeRef = React.useRef<HTMLDivElement>(null);
  const hsvaRef = React.useRef(hsva);
  hsvaRef.current = hsva;

  React.useEffect(() => {
    if (!showEyeDropper) return;
    setCanEyeDrop("EyeDropper" in window);
  }, [showEyeDropper]);

  const apply = React.useCallback(
    (next: HsvaColor) => {
      const normalized = showAlpha ? next : { ...next, a: 1 };
      setHsva(normalized);
      const hex = formatHsva(normalized, "hex");
      lastEmitted.current = hex;
      onValueChange?.(hex, normalized);
    },
    [onValueChange, showAlpha],
  );

  /** Push the current color into the recents row (dedup, max 8). */
  const commit = React.useCallback(() => {
    const hex = formatHsva(hsvaRef.current, "hex");
    setRecent((prev) => [hex, ...prev.filter((c) => c !== hex)].slice(0, 8));
  }, []);

  // ---- Saturation/value plane -------------------------------------------
  const planeFromPointer = (event: React.PointerEvent) => {
    const plane = planeRef.current;
    if (!plane) return;
    const rect = plane.getBoundingClientRect();
    const s = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100);
    const v = clamp((1 - (event.clientY - rect.top) / rect.height) * 100, 0, 100);
    apply({ ...hsvaRef.current, s, v });
  };

  const onPlaneKeyDown = (event: React.KeyboardEvent) => {
    if (disabled) return;
    const step = event.shiftKey ? 10 : 1;
    const current = hsvaRef.current;
    const moves: Record<string, Partial<HsvaColor>> = {
      ArrowRight: { s: clamp(current.s + step, 0, 100) },
      ArrowLeft: { s: clamp(current.s - step, 0, 100) },
      ArrowUp: { v: clamp(current.v + step, 0, 100) },
      ArrowDown: { v: clamp(current.v - step, 0, 100) },
    };
    if (event.key in moves) {
      event.preventDefault();
      apply({ ...current, ...moves[event.key] });
      commit();
    }
  };

  // ---- Text field ---------------------------------------------------------
  const display = formatHsva(hsva, format);
  const commitDraft = () => {
    if (draft !== null) {
      const parsed = parseColor(draft);
      if (parsed) {
        apply(parsed);
        commit();
      }
    }
    setDraft(null);
  };

  const pickFromScreen = async () => {
    const Ctor = (window as { EyeDropper?: EyeDropperConstructor }).EyeDropper;
    if (!Ctor) return;
    try {
      const result = await new Ctor().open();
      const parsed = parseColor(result.sRGBHex);
      if (parsed) {
        apply({ ...parsed, a: hsvaRef.current.a });
        commit();
      }
    } catch {
      // User dismissed the eyedropper — nothing to apply.
    }
  };

  const hueCss = `hsl(${Math.round(hsva.h)} 100% 50%)`;
  const opaque = formatHsva({ ...hsva, a: 1 }, "hex");
  const current = formatHsva(hsva, "hex");
  const sm = size === "sm";

  return (
    <div
      data-slot="color-picker"
      className={cn(
        "flex flex-col gap-2.5",
        sm ? "w-52" : "w-60",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    >
      {/* Saturation / value plane */}
      <div
        ref={planeRef}
        role="slider"
        tabIndex={disabled ? -1 : 0}
        aria-label="Saturation and brightness"
        aria-valuenow={Math.round(hsva.v)}
        aria-valuetext={`Saturation ${Math.round(hsva.s)}%, brightness ${Math.round(hsva.v)}%`}
        onPointerDown={(event) => {
          if (disabled || event.button !== 0) return;
          event.preventDefault();
          event.currentTarget.setPointerCapture(event.pointerId);
          event.currentTarget.focus();
          setPlaneDragging(true);
          planeFromPointer(event);
        }}
        onPointerMove={(event) => {
          if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
          planeFromPointer(event);
        }}
        onPointerUp={(event) => {
          if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
          event.currentTarget.releasePointerCapture(event.pointerId);
          setPlaneDragging(false);
          commit();
        }}
        onPointerCancel={(event) => {
          if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
          event.currentTarget.releasePointerCapture(event.pointerId);
          setPlaneDragging(false);
        }}
        onKeyDown={onPlaneKeyDown}
        className={cn(
          "relative w-full cursor-crosshair touch-none rounded-md outline-none select-none",
          sm ? "h-28" : "h-36",
          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        )}
        style={{
          backgroundColor: hueCss,
          backgroundImage:
            "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
        }}
      >
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-md shadow-[inset_0_0_0_1px_rgba(128,128,128,0.2)]"
        />
        <span
          aria-hidden
          className={cn(THUMB_CLASSES, "size-4", planeDragging && "scale-110")}
          style={{
            left: `${hsva.s}%`,
            top: `${100 - hsva.v}%`,
            backgroundColor: opaque,
          }}
        />
      </div>

      {/* Rails + eyedropper */}
      <div className="flex items-center gap-2.5">
        <div className="flex min-w-0 flex-1 flex-col gap-2">
          <Rail
            label="Hue"
            value={hsva.h}
            max={360}
            step={1}
            bigStep={10}
            disabled={disabled}
            onChange={(h) => apply({ ...hsvaRef.current, h: Math.min(h, 359.9) })}
            onCommit={commit}
            trackStyle={{ backgroundImage: HUE_GRADIENT }}
            thumbColor={hueCss}
            valueText={`${Math.round(hsva.h)}°`}
          />
          {showAlpha && (
            <Rail
              label="Alpha"
              value={hsva.a}
              max={1}
              step={0.01}
              bigStep={0.1}
              disabled={disabled}
              onChange={(a) => apply({ ...hsvaRef.current, a: round2(a) })}
              onCommit={commit}
              trackStyle={CHECKER}
              trackChildren={
                <span
                  className="absolute inset-0"
                  style={{
                    backgroundImage: `linear-gradient(to right, transparent, ${opaque})`,
                  }}
                />
              }
              thumbColor={current}
              valueText={`${Math.round(hsva.a * 100)}%`}
            />
          )}
        </div>
        {showEyeDropper && canEyeDrop && (
          <button
            type="button"
            aria-label="Pick color from screen"
            disabled={disabled}
            onClick={pickFromScreen}
            className={cn(
              "pressable relative flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground shadow-border",
              "transition-[color,box-shadow] duration-150 ease-out",
              "hover:text-foreground hover:shadow-border-hover",
              "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
            )}
          >
            <Pipette aria-hidden className="size-4" />
          </button>
        )}
      </div>

      {/* Format cycle + value field */}
      <div
        className={cn(
          "flex h-8 items-center gap-1 rounded-md border border-input",
          "transition-[border-color,box-shadow] duration-150 ease-out",
          "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
        )}
      >
        <button
          type="button"
          aria-label={`Color format: ${format.toUpperCase()}. Switch format`}
          disabled={disabled}
          onClick={() =>
            setFormat(
              (prev) => FORMATS[(FORMATS.indexOf(prev) + 1) % FORMATS.length],
            )
          }
          className={cn(
            "pressable relative ml-1 flex h-6 shrink-0 items-center gap-0.5 rounded px-1.5",
            "text-[10px] font-semibold tracking-wide text-muted-foreground uppercase",
            "transition-colors duration-150 ease-out hover:bg-secondary hover:text-foreground",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring",
            "after:absolute after:top-1/2 after:left-1/2 after:h-10 after:w-full after:min-w-10 after:-translate-1/2",
          )}
        >
          {format}
          <ChevronsUpDown aria-hidden className="size-2.5" />
        </button>
        <input
          type="text"
          aria-label="Color value"
          spellCheck={false}
          autoComplete="off"
          disabled={disabled}
          value={draft ?? display}
          onChange={(event) => setDraft(event.target.value)}
          onFocus={(event) => {
            setDraft(display);
            event.currentTarget.select();
          }}
          onBlur={commitDraft}
          onKeyDown={(event) => {
            if (event.key === "Enter") {
              event.preventDefault();
              commitDraft();
              event.currentTarget.blur();
            } else if (event.key === "Escape") {
              event.preventDefault();
              setDraft(null);
              event.currentTarget.blur();
            }
          }}
          className="h-full min-w-0 flex-1 bg-transparent pr-2 font-mono text-xs text-foreground tabular-nums outline-none"
        />
      </div>

      {/* Recent swatches */}
      {recent.length > 0 && (
        <div role="group" aria-label="Recent colors" className="flex flex-wrap gap-1.5">
          {recent.map((swatch) => {
            const active = swatch === current;
            return (
              <button
                key={swatch}
                type="button"
                aria-label={`Use ${swatch}`}
                aria-pressed={active}
                disabled={disabled}
                onClick={() => {
                  const parsed = parseColor(swatch);
                  if (parsed) {
                    apply(parsed);
                    commit();
                  }
                }}
                className={cn(
                  "pressable relative size-5 rounded-[5px]",
                  "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                  "after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2",
                  active && "ring-2 ring-ring ring-offset-2 ring-offset-background",
                )}
                style={CHECKER}
              >
                <span
                  aria-hidden
                  className="absolute inset-0 rounded-[5px] shadow-[inset_0_0_0_1px_rgba(128,128,128,0.3)]"
                  style={{ backgroundColor: swatch }}
                />
              </button>
            );
          })}
        </div>
      )}
      {name && <input type="hidden" name={name} value={current} />}
    </div>
  );
}