deepmoon

Color Picker

The saturation/lightness area and hue/alpha sliders are keyboard-operable (click to focus, then use arrow keys), but the hex and RGB/HSL text inputs below are the accessible path of record, fully usable with a screen reader on their own.

Default

RGB
#3b82f6

With alpha

RGB
#3b82f680

No presets

RGB

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/color-picker.json

npm dependencies: lucide-react

Props

ColorPickerProps
PropTypeRequired
valuestringYes
onChange(value: string) => voidYes
alphabooleanNo
presetsstring[]No
classNamestringNo

Code

color-picker.tsx
"use client";

import * as React from "react";
import { Check, Copy } from "lucide-react";

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

interface HSV {
  h: number;
  s: number;
  v: number;
}

interface RGB {
  r: number;
  g: number;
  b: number;
}

interface HSL {
  h: number;
  s: number;
  l: number;
}

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

function hsvToRgb({ h, s, v }: HSV): RGB {
  const sN = s / 100;
  const vN = v / 100;
  const c = vN * sN;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = vN - c;
  const [r, g, b] =
    h < 60
      ? [c, x, 0]
      : h < 120
        ? [x, c, 0]
        : h < 180
          ? [0, c, x]
          : h < 240
            ? [0, x, c]
            : h < 300
              ? [x, 0, c]
              : [c, 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): HSV {
  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;
  }
  const v = max;
  const s = max === 0 ? 0 : d / max;
  return { h, s: s * 100, v: v * 100 };
}

function hsvToHsl({ h, s, v }: HSV): HSL {
  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 };
}

function hslToRgb({ h, s, l }: HSL): RGB {
  const sN = s / 100;
  const lN = l / 100;
  const c = (1 - Math.abs(2 * lN - 1)) * sN;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = lN - c / 2;
  const [r, g, b] =
    h < 60
      ? [c, x, 0]
      : h < 120
        ? [x, c, 0]
        : h < 180
          ? [0, c, x]
          : h < 240
            ? [0, x, c]
            : h < 300
              ? [x, 0, c]
              : [c, 0, x];
  return {
    r: Math.round((r + m) * 255),
    g: Math.round((g + m) * 255),
    b: Math.round((b + m) * 255),
  };
}

function rgbToHex({ r, g, b }: RGB): string {
  const toHex = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}

function hexToRgb(hex: string): RGB | null {
  const match = hex.replace(/^#/, "").match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i);
  if (!match) return null;
  return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16) };
}

function hexToAlpha(hex: string): number | null {
  const clean = hex.replace(/^#/, "");
  if (clean.length !== 8) return null;
  return parseInt(clean.slice(6, 8), 16) / 255;
}

export const DEFAULT_COLOR_PRESETS: string[] = [
  "#ef4444",
  "#f97316",
  "#f59e0b",
  "#84cc16",
  "#10b981",
  "#06b6d4",
  "#3b82f6",
  "#8b5cf6",
  "#ec4899",
  "#78716c",
  "#171717",
  "#ffffff",
];

export interface ColorPickerProps {
  /** Hex color: "#rrggbb", or "#rrggbbaa" when `alpha` is enabled. */
  value: string;
  onChange: (value: string) => void;
  alpha?: boolean;
  presets?: string[];
  className?: string;
}

/**
 * Every visual control here (the saturation/lightness area, the hue and
 * alpha sliders) is keyboard-operable and labeled, but none of them is the
 * accessible path of record — a 2D area has no faithful single ARIA role,
 * so it's exposed as a labeled, focusable group with a live-region value
 * announcement on change rather than mislabeled as role="slider". The hex
 * and RGB/HSL text inputs are ordinary native inputs and are the actual
 * accessible interface: fully operable with a screen reader, independent
 * of the gradient controls.
 */
export function ColorPicker({ value, onChange, alpha = false, presets = DEFAULT_COLOR_PRESETS, className }: ColorPickerProps) {
  const [hsv, setHsv] = React.useState<HSV>(() => rgbToHsv(hexToRgb(value) ?? { r: 0, g: 0, b: 0 }));
  const [alphaValue, setAlphaValue] = React.useState<number>(() => hexToAlpha(value) ?? 1);
  const [format, setFormat] = React.useState<"rgb" | "hsl">("rgb");
  const [hexDraft, setHexDraft] = React.useState(value);
  const [copied, setCopied] = React.useState(false);
  const lastEmitted = React.useRef<string | null>(null);
  const svRef = React.useRef<HTMLDivElement>(null);
  const hueRef = React.useRef<HTMLDivElement>(null);
  const alphaRef = React.useRef<HTMLDivElement>(null);
  const svLiveRef = React.useRef<HTMLSpanElement>(null);
  const svId = React.useId();
  const hexId = React.useId();

  React.useEffect(() => {
    if (value === lastEmitted.current) return;
    const rgb = hexToRgb(value);
    if (!rgb) return;
    setHsv(rgbToHsv(rgb));
    const a = hexToAlpha(value);
    setAlphaValue(a ?? 1);
    setHexDraft(value);
  }, [value]);

  const rgb = hsvToRgb(hsv);
  const hex = rgbToHex(rgb);
  const hsl = hsvToHsl(hsv);

  const emit = (nextHsv: HSV, nextAlpha: number) => {
    const nextRgb = hsvToRgb(nextHsv);
    let nextHex = rgbToHex(nextRgb);
    if (alpha) nextHex += Math.round(nextAlpha * 255).toString(16).padStart(2, "0");
    lastEmitted.current = nextHex;
    setHexDraft(nextHex);
    onChange(nextHex);
  };

  const updateSv = (clientX: number, clientY: number) => {
    const rect = svRef.current?.getBoundingClientRect();
    if (!rect) return;
    const s = clamp(((clientX - rect.left) / rect.width) * 100, 0, 100);
    const v = clamp(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100);
    const next = { ...hsv, s, v };
    setHsv(next);
    emit(next, alphaValue);
    if (svLiveRef.current) {
      svLiveRef.current.textContent = `Saturation ${Math.round(s)}%, lightness ${Math.round(v)}%`;
    }
  };

  const updateHue = (clientX: number) => {
    const rect = hueRef.current?.getBoundingClientRect();
    if (!rect) return;
    const h = clamp(((clientX - rect.left) / rect.width) * 360, 0, 360);
    const next = { ...hsv, h };
    setHsv(next);
    emit(next, alphaValue);
  };

  const updateAlpha = (clientX: number) => {
    const rect = alphaRef.current?.getBoundingClientRect();
    if (!rect) return;
    const a = clamp((clientX - rect.left) / rect.width, 0, 1);
    setAlphaValue(a);
    emit(hsv, a);
  };

  const commitHex = (raw: string) => {
    const rgbFromHex = hexToRgb(raw);
    if (!rgbFromHex) {
      setHexDraft(hex + (alpha ? Math.round(alphaValue * 255).toString(16).padStart(2, "0") : ""));
      return;
    }
    const next = rgbToHsv(rgbFromHex);
    setHsv(next);
    const a = alpha ? (hexToAlpha(raw) ?? alphaValue) : alphaValue;
    setAlphaValue(a);
    emit(next, a);
  };

  const setRgbChannel = (channel: keyof RGB, n: number) => {
    const nextRgb = { ...rgb, [channel]: clamp(n, 0, 255) };
    const next = rgbToHsv(nextRgb);
    setHsv(next);
    emit(next, alphaValue);
  };

  const setHslChannel = (channel: keyof HSL, n: number) => {
    const bounds: Record<keyof HSL, [number, number]> = { h: [0, 360], s: [0, 100], l: [0, 100] };
    const nextHsl = { ...hsl, [channel]: clamp(n, ...bounds[channel]) };
    const next = rgbToHsv(hslToRgb(nextHsl));
    setHsv(next);
    emit(next, alphaValue);
  };

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(hex + (alpha ? Math.round(alphaValue * 255).toString(16).padStart(2, "0") : ""));
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {
      // clipboard API unavailable (e.g. insecure context) — no-op
    }
  };

  return (
    <div className={cn("flex w-full max-w-xs flex-col gap-3", className)}>
      <div
        ref={svRef}
        id={svId}
        role="group"
        aria-label="Saturation and lightness. Use arrow keys to adjust."
        tabIndex={0}
        onPointerDown={(event) => {
          event.currentTarget.setPointerCapture(event.pointerId);
          updateSv(event.clientX, event.clientY);
        }}
        onPointerMove={(event) => {
          if (event.buttons !== 1) return;
          updateSv(event.clientX, event.clientY);
        }}
        onKeyDown={(event) => {
          const step = event.shiftKey ? 10 : 2;
          let next: HSV | null = null;
          if (event.key === "ArrowRight") next = { ...hsv, s: clamp(hsv.s + step, 0, 100) };
          else if (event.key === "ArrowLeft") next = { ...hsv, s: clamp(hsv.s - step, 0, 100) };
          else if (event.key === "ArrowUp") next = { ...hsv, v: clamp(hsv.v + step, 0, 100) };
          else if (event.key === "ArrowDown") next = { ...hsv, v: clamp(hsv.v - step, 0, 100) };
          if (!next) return;
          event.preventDefault();
          setHsv(next);
          emit(next, alphaValue);
          if (svLiveRef.current) {
            svLiveRef.current.textContent = `Saturation ${Math.round(next.s)}%, lightness ${Math.round(next.v)}%`;
          }
        }}
        className="relative h-40 w-full touch-none rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        style={{
          backgroundColor: `hsl(${hsv.h} 100% 50%)`,
          backgroundImage:
            "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
        }}
      >
        <span
          aria-hidden="true"
          className="pointer-events-none absolute size-3.5 -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)]"
          style={{ left: `${hsv.s}%`, top: `${100 - hsv.v}%`, backgroundColor: hex }}
        />
      </div>
      <span ref={svLiveRef} aria-live="polite" className="sr-only" />

      <div
        ref={hueRef}
        role="slider"
        aria-label="Hue"
        aria-orientation="horizontal"
        aria-valuenow={Math.round(hsv.h)}
        aria-valuemin={0}
        aria-valuemax={360}
        tabIndex={0}
        onPointerDown={(event) => {
          event.currentTarget.setPointerCapture(event.pointerId);
          updateHue(event.clientX);
        }}
        onPointerMove={(event) => {
          if (event.buttons !== 1) return;
          updateHue(event.clientX);
        }}
        onKeyDown={(event) => {
          const step = event.shiftKey ? 10 : 1;
          let h: number | null = null;
          if (event.key === "ArrowRight" || event.key === "ArrowUp") h = clamp(hsv.h + step, 0, 360);
          else if (event.key === "ArrowLeft" || event.key === "ArrowDown") h = clamp(hsv.h - step, 0, 360);
          if (h === null) return;
          event.preventDefault();
          const next = { ...hsv, h };
          setHsv(next);
          emit(next, alphaValue);
        }}
        className="relative h-3 w-full touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        style={{
          backgroundImage:
            "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
        }}
      >
        <span
          aria-hidden="true"
          className="pointer-events-none absolute top-1/2 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)]"
          style={{ left: `${(hsv.h / 360) * 100}%`, backgroundColor: `hsl(${hsv.h} 100% 50%)` }}
        />
      </div>

      {alpha ? (
        <div
          ref={alphaRef}
          role="slider"
          aria-label="Alpha"
          aria-orientation="horizontal"
          aria-valuenow={Math.round(alphaValue * 100)}
          aria-valuemin={0}
          aria-valuemax={100}
          tabIndex={0}
          onPointerDown={(event) => {
            event.currentTarget.setPointerCapture(event.pointerId);
            updateAlpha(event.clientX);
          }}
          onPointerMove={(event) => {
            if (event.buttons !== 1) return;
            updateAlpha(event.clientX);
          }}
          onKeyDown={(event) => {
            const step = (event.shiftKey ? 10 : 1) / 100;
            let a: number | null = null;
            if (event.key === "ArrowRight" || event.key === "ArrowUp") a = clamp(alphaValue + step, 0, 1);
            else if (event.key === "ArrowLeft" || event.key === "ArrowDown") a = clamp(alphaValue - step, 0, 1);
            if (a === null) return;
            event.preventDefault();
            setAlphaValue(a);
            emit(hsv, a);
          }}
          className="relative h-3 w-full touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          style={{
            backgroundImage: `linear-gradient(to right, transparent, ${hex}), repeating-conic-gradient(#d4d4d4 0% 25%, transparent 0% 50%)`,
            backgroundSize: "100% 100%, 0.5rem 0.5rem",
          }}
        >
          <span
            aria-hidden="true"
            className="pointer-events-none absolute top-1/2 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)]"
            style={{ left: `${alphaValue * 100}%`, backgroundColor: hex }}
          />
        </div>
      ) : null}

      <div className="flex items-end gap-2">
        <div className="flex flex-1 flex-col gap-1">
          <label htmlFor={hexId} className="text-xs font-medium text-muted-foreground">
            Hex
          </label>
          <input
            id={hexId}
            type="text"
            value={hexDraft}
            onChange={(event) => setHexDraft(event.target.value)}
            onBlur={(event) => commitHex(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter") commitHex(event.currentTarget.value);
            }}
            spellCheck={false}
            className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
          />
        </div>
        <button
          type="button"
          onClick={handleCopy}
          aria-label="Copy color value"
          className="mb-[3px] flex size-8 shrink-0 items-center justify-center rounded-md border border-input text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
        >
          {copied ? <Check className="size-4" aria-hidden="true" /> : <Copy className="size-4" aria-hidden="true" />}
        </button>
        <span aria-live="polite" className="sr-only">
          {copied ? "Copied to clipboard" : ""}
        </span>
      </div>

      <div className="flex flex-col gap-1">
        <div className="flex items-center justify-between">
          <span className="text-xs font-medium text-muted-foreground">{format.toUpperCase()}</span>
          <div role="group" aria-label="Color format" className="flex gap-1">
            {(["rgb", "hsl"] as const).map((f) => (
              <button
                key={f}
                type="button"
                aria-pressed={format === f}
                onClick={() => setFormat(f)}
                className={cn(
                  "rounded px-2 py-0.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                  format === f
                    ? "bg-secondary text-secondary-foreground"
                    : "text-muted-foreground hover:text-foreground",
                )}
              >
                {f.toUpperCase()}
              </button>
            ))}
          </div>
        </div>
        <div className="grid grid-cols-3 gap-2">
          {format === "rgb"
            ? (["r", "g", "b"] as const).map((channel) => (
                <div key={channel} className="flex flex-col gap-1">
                  <label htmlFor={`${hexId}-${channel}`} className="text-xs text-muted-foreground uppercase">
                    {channel}
                  </label>
                  <input
                    id={`${hexId}-${channel}`}
                    type="number"
                    min={0}
                    max={255}
                    value={Math.round(rgb[channel])}
                    onChange={(event) => setRgbChannel(channel, Number(event.target.value))}
                    className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  />
                </div>
              ))
            : (["h", "s", "l"] as const).map((channel) => (
                <div key={channel} className="flex flex-col gap-1">
                  <label htmlFor={`${hexId}-${channel}`} className="text-xs text-muted-foreground uppercase">
                    {channel}
                  </label>
                  <input
                    id={`${hexId}-${channel}`}
                    type="number"
                    min={0}
                    max={channel === "h" ? 360 : 100}
                    value={Math.round(hsl[channel])}
                    onChange={(event) => setHslChannel(channel, Number(event.target.value))}
                    className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  />
                </div>
              ))}
        </div>
      </div>

      {presets.length > 0 ? (
        <div role="group" aria-label="Preset colors" className="flex flex-wrap gap-2">
          {presets.map((preset) => (
            <button
              key={preset}
              type="button"
              onClick={() => commitHex(preset)}
              aria-label={`Set color to ${preset}`}
              aria-pressed={preset.toLowerCase() === hex.toLowerCase()}
              className={cn(
                "size-6 shrink-0 rounded-full border border-border outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                preset.toLowerCase() === hex.toLowerCase() && "ring-2 ring-ring ring-offset-2 ring-offset-background",
              )}
              style={{ backgroundColor: preset }}
            />
          ))}
        </div>
      ) : null}
    </div>
  );
}