Bloom Logo

Color Picker

A visual color selection component featuring a native color input preview, HEX/RGB/HSL format switcher with copy button, and an interactive canvas-based color wheel.

importimport { ColorPicker } from "@/components/ui/colorPicker/colorPicker";
$npx @bloomui-react/cli add colorPicker
colorPicker.tsx
"use client";

import { Icon } from "@iconify/react";
import * as React from "react";
import { cn } from "@/lib/utils";

function hexToRgb(hex: string): { r: number; g: number; b: number } {
  const cleaned = hex.replace("#", "");
  const bigint = parseInt(cleaned, 16);
  return {
    r: (bigint >> 16) & 255,
    g: (bigint >> 8) & 255,
    b: bigint & 255,
  };
}

function rgbToHex(r: number, g: number, b: number): string {
  return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}

function rgbToHsl(
  r: number,
  g: number,
  b: number,
): { h: number; s: number; l: number } {
  r /= 255;
  g /= 255;
  b /= 255;
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0;
  let s = 0;
  const l = (max + min) / 2;

  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r:
        h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
        break;
      case g:
        h = ((b - r) / d + 2) / 6;
        break;
      case b:
        h = ((r - g) / d + 4) / 6;
        break;
    }
  }

  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}

function hslToRgb(
  h: number,
  s: number,
  l: number,
): { r: number; g: number; b: number } {
  h /= 360;
  s /= 100;
  l /= 100;

  let r: number, g: number, b: number;

  if (s === 0) {
    r = g = b = l;
  } else {
    const hue2rgb = (p: number, q: number, t: number) => {
      if (t < 0) t += 1;
      if (t > 1) t -= 1;
      if (t < 1 / 6) return p + (q - p) * 6 * t;
      if (t < 1 / 2) return q;
      if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
      return p;
    };
    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const p = 2 * l - q;
    r = hue2rgb(p, q, h + 1 / 3);
    g = hue2rgb(p, q, h);
    b = hue2rgb(p, q, h - 1 / 3);
  }

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

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

export interface ColorPickerProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (color: string) => void;
  label?: React.ReactNode;
  disabled?: boolean;
  showWheel?: boolean;
  showFormatSwitcher?: boolean;
  defaultFormat?: ColorFormat;
  isRequired?: boolean;
  isInvalid?: boolean;
}

function ColorWheel({
  value,
  onChange,
  size = 180,
  disabled = false,
}: {
  value: string;
  onChange: (hex: string) => void;
  size?: number;
  disabled?: boolean;
}) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const [isDragging, setIsDragging] = React.useState(false);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const centerX = size / 2;
    const centerY = size / 2;
    const radius = size / 2 - 4;

    ctx.clearRect(0, 0, size, size);

    for (let angle = 0; angle < 360; angle++) {
      const startAngle = ((angle - 1) * Math.PI) / 180;
      const endAngle = ((angle + 1) * Math.PI) / 180;

      for (let sat = 0; sat <= 100; sat += 2) {
        const innerRadius = (sat / 100) * radius;
        const outerRadius = ((sat + 2) / 100) * radius;
        ctx.beginPath();
        ctx.arc(centerX, centerY, innerRadius, startAngle, endAngle);
        ctx.arc(centerX, centerY, outerRadius, endAngle, startAngle, true);
        ctx.closePath();
        ctx.fillStyle = `hsl(${angle}, ${sat}%, 50%)`;
        ctx.fill();
      }
    }
  }, [size]);

  const pickColor = React.useCallback(
    (
      e:
        | React.MouseEvent<HTMLCanvasElement>
        | React.TouchEvent<HTMLCanvasElement>,
    ) => {
      if (disabled) return;
      const canvas = canvasRef.current;
      if (!canvas) return;

      const rect = canvas.getBoundingClientRect();
      const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
      const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;

      const x = clientX - rect.left;
      const y = clientY - rect.top;
      const centerX = size / 2;
      const centerY = size / 2;
      const radius = size / 2 - 4;

      const dx = x - centerX;
      const dy = y - centerY;
      const dist = Math.sqrt(dx * dx + dy * dy);

      if (dist <= radius) {
        let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
        if (angle < 0) angle += 360;
        const saturation = Math.min((dist / radius) * 100, 100);

        const rgb = hslToRgb(Math.round(angle), Math.round(saturation), 50);
        onChange(rgbToHex(rgb.r, rgb.g, rgb.b));
      }
    },
    [disabled, size, onChange],
  );

  const centerX = size / 2;
  const centerY = size / 2;
  const radius = size / 2 - 4;

  const rgb = hexToRgb(value);
  const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
  const angleRad = (hsl.h * Math.PI) / 180;
  const dist = (hsl.s / 100) * radius;
  const selectorX = centerX + dist * Math.cos(angleRad);
  const selectorY = centerY + dist * Math.sin(angleRad);

  return (
    <div className="relative" style={{ width: size, height: size }}>
      <canvas
        ref={canvasRef}
        width={size}
        height={size}
        className={cn(
          "rounded-full cursor-crosshair select-none touch-none",
          disabled && "opacity-50 pointer-events-none",
        )}
        onMouseDown={(e) => {
          setIsDragging(true);
          pickColor(e);
        }}
        onMouseMove={(e) => isDragging && pickColor(e)}
        onMouseUp={() => setIsDragging(false)}
        onMouseLeave={() => setIsDragging(false)}
        onTouchStart={(e) => {
          setIsDragging(true);
          pickColor(e);
        }}
        onTouchMove={(e) => isDragging && pickColor(e)}
        onTouchEnd={() => setIsDragging(false)}
      />
      <div
        className="absolute border-2 border-white rounded-full pointer-events-none shadow-md"
        style={{
          left: `${selectorX}px`,
          top: `${selectorY}px`,
          width: "16px",
          height: "16px",
          transform: "translate(-50%, -50%)",
          boxShadow: "0 0 0 1px rgba(0,0,0,0.25)",
        }}
      />
    </div>
  );
}

export function ColorPicker({
  value,
  defaultValue = "#3b82f6",
  onValueChange,
  label,
  disabled = false,
  showWheel = false,
  showFormatSwitcher = true,
  defaultFormat = "hex",
  isRequired = false,
  isInvalid: _isInvalid = false,
}: ColorPickerProps) {
  const [color, setColor] = React.useState<string>(
    value !== undefined ? value : defaultValue,
  );
  const [format, setFormat] = React.useState<ColorFormat>(defaultFormat);
  const [copied, setCopied] = React.useState(false);

  React.useEffect(() => {
    if (value !== undefined) {
      setColor(value);
    }
  }, [value]);

  const handleChange = (newColor: string) => {
    setColor(newColor);
    onValueChange?.(newColor);
  };

  const rgb = hexToRgb(color);
  const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);

  const formatDisplay = () => {
    switch (format) {
      case "rgb":
        return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
      case "hsl":
        return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
      default:
        return color.toUpperCase();
    }
  };

  const formatLabel = () => {
    switch (format) {
      case "rgb":
        return "RGB";
      case "hsl":
        return "HSL";
      default:
        return "HEX";
    }
  };

  const handleTextChange = (text: string) => {
    if (format === "hex") {
      const cleaned = text.replace(/[^#0-9a-fA-F]/g, "");
      const hex = cleaned.startsWith("#") ? cleaned : `#${cleaned}`;
      if (hex.length === 7 && /^#[0-9a-fA-F]{6}$/.test(hex)) {
        handleChange(hex.toLowerCase());
      }
    } else if (format === "rgb") {
      const match = text.match(/(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/);
      if (match) {
        const [, rs, gs, bs] = match;
        const r = Math.min(255, parseInt(rs, 10));
        const g = Math.min(255, parseInt(gs, 10));
        const b = Math.min(255, parseInt(bs, 10));
        handleChange(rgbToHex(r, g, b));
      }
    } else if (format === "hsl") {
      const match = text.match(/(\d{1,3})\s*,\s*(\d{1,3})%?\s*,\s*(\d{1,3})%?/);
      if (match) {
        const [, hs, ss, ls] = match;
        const h = Math.min(360, parseInt(hs, 10));
        const s = Math.min(100, parseInt(ss, 10));
        const l = Math.min(100, parseInt(ls, 10));
        const converted = hslToRgb(h, s, l);
        handleChange(rgbToHex(converted.r, converted.g, converted.b));
      }
    }
  };

  const handleCopy = () => {
    navigator.clipboard.writeText(formatDisplay());
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };

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

  const cycleFormat = () => {
    const idx = formats.indexOf(format);
    setFormat(formats[(idx + 1) % formats.length]);
  };

  return (
    <div className="flex flex-col gap-3 w-full max-w-xs">
      {label && (
        <label className="text-xs font-semibold text-zinc-900 dark:text-zinc-100 select-none">
          {label}
          {isRequired && <span className="text-rose-500 ml-0.5">*</span>}
        </label>
      )}

      {showWheel && (
        <div className="flex justify-center">
          <ColorWheel
            value={color}
            onChange={handleChange}
            disabled={disabled}
          />
        </div>
      )}

      <div className="flex items-center gap-2.5">
        <div className="relative size-10 rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-xs shrink-0 cursor-pointer">
          <input
            type="color"
            value={color}
            disabled={disabled}
            onChange={(e) => handleChange(e.target.value)}
            className="absolute -top-2 -left-2 size-14 cursor-pointer outline-none border-0"
          />
        </div>

        <div className="flex items-center flex-1">
          {showFormatSwitcher && (
            <button
              type="button"
              disabled={disabled}
              onClick={cycleFormat}
              className="h-10 px-2.5 rounded-l-xl border border-r-0 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-800 text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {formatLabel()}
            </button>
          )}
          <input
            type="text"
            value={formatDisplay()}
            disabled={disabled}
            onChange={(e) => handleTextChange(e.target.value)}
            className={cn(
              "h-10 w-full border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2 text-xs font-mono text-zinc-900 dark:text-zinc-100 outline-none focus:ring-2 focus:ring-sky-500/40 transition-shadow disabled:opacity-50 disabled:cursor-not-allowed",
              showFormatSwitcher ? "border-l-0" : "rounded-l-xl",
            )}
          />
          <button
            type="button"
            disabled={disabled}
            onClick={handleCopy}
            className="h-10 px-3 rounded-r-xl border border-l-0 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
            aria-label="Copy color code"
          >
            <Icon
              icon={copied ? "hugeicons:tick-02" : "hugeicons:copy-01"}
              className={cn("size-3.5", copied && "text-emerald-500")}
            />
          </button>
        </div>
      </div>
    </div>
  );
}

Default

Standard color picker with swatch preview, HEX/RGB/HSL format switcher, and a copy button to copy the color code in the selected format.

Color Wheel

Enable the interactive canvas-based color wheel for precise hue and saturation selection by setting showWheel to true.

showWheel: boolean

Format Switcher (HEX / RGB / HSL)

Click the format label button to cycle through HEX, RGB, and HSL representations. The copy button copies the value in the active format. Set defaultFormat to start in a specific mode.

defaultFormat: hex | rgb | hslshowFormatSwitcher: boolean
defaultFormat="hex"
defaultFormat="rgb"
defaultFormat="hsl"

Disabled State

Disables all interaction including the color input, format switcher, copy button, and wheel.

disabled: boolean
API Reference

Required State

Displays an asterisk next to the label indicating that choosing a color is mandatory.

isRequired: boolean

Props — ColorPicker

Properties to configure the ColorPicker component.

PropTypeDefaultDescription
valuestringControlled HEX color value string.
defaultValuestring'#3b82f6'Initial color value for uncontrolled mode.
onValueChange(color: string) => voidCallback fired when the selected color changes.
showWheelbooleanfalseRenders the interactive canvas-based color wheel.
showFormatSwitcherbooleantrueShows the HEX/RGB/HSL format toggle button.
defaultFormat'hex' | 'rgb' | 'hsl''hex'Starting color format for the text display.
labelReactNodeLabel text rendered above the color picker.
disabledbooleanfalseDisables all user interaction with the picker.