← Back
UI

Color Picker

HSV picker — saturation/value square and hue strip rendered on canvas, with hex copy.

#6B24B3

rgb(107, 36, 179)

ColorPicker.tsx146 lines
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

function hsvToHex(h: number, s: number, v: number): string {
  const f = (n: number) => {
    const k = (n + h / 60) % 6;
    const val = v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
    return Math.round(val * 255).toString(16).padStart(2, "0");
  };
  return `#${f(5)}${f(3)}${f(1)}`;
}

function hexToRgb(hex: string) {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return { r, g, b };
}

export default function ColorPicker() {
  const sqRef   = useRef<HTMLCanvasElement>(null);
  const hueRef  = useRef<HTMLCanvasElement>(null);

  const [hue, setHue]   = useState(270);
  const [sat, setSat]   = useState(0.8);
  const [val, setVal]   = useState(0.7);
  const [copied, setCopied] = useState(false);

  const hex = hsvToHex(hue, sat, val);
  const { r, g, b } = hexToRgb(hex);

  // Draw saturation/value square
  const drawSquare = useCallback(() => {
    const canvas = sqRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const { width: W, height: H } = canvas;

    const gH = ctx.createLinearGradient(0, 0, W, 0);
    gH.addColorStop(0, "#fff");
    gH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
    ctx.fillStyle = gH;
    ctx.fillRect(0, 0, W, H);

    const gV = ctx.createLinearGradient(0, 0, 0, H);
    gV.addColorStop(0, "rgba(0,0,0,0)");
    gV.addColorStop(1, "rgba(0,0,0,1)");
    ctx.fillStyle = gV;
    ctx.fillRect(0, 0, W, H);
  }, [hue]);

  // Draw hue strip
  useEffect(() => {
    const canvas = hueRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const g = ctx.createLinearGradient(0, 0, canvas.width, 0);
    for (let i = 0; i <= 360; i += 30) g.addColorStop(i / 360, `hsl(${i},100%,50%)`);
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, canvas.width, canvas.height);
  }, []);

  useEffect(() => { drawSquare(); }, [drawSquare]);

  function pickSquare(e: React.MouseEvent<HTMLCanvasElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    setSat(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)));
    setVal(Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height)));
  }

  function pickHue(e: React.MouseEvent<HTMLCanvasElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    setHue(Math.round(Math.max(0, Math.min(360, ((e.clientX - r.left) / r.width) * 360))));
  }

  function copy() {
    navigator.clipboard.writeText(hex.toUpperCase());
    setCopied(true);
    setTimeout(() => setCopied(false), 1800);
  }

  return (
    <div className="flex flex-col gap-4 py-6 w-full max-w-xs mx-auto">
      {/* SV square */}
      <div className="relative rounded-xl overflow-hidden" style={{ height: 160 }}>
        <canvas
          ref={sqRef}
          width={280}
          height={160}
          className="w-full h-full cursor-crosshair block"
          onClick={pickSquare}
        />
        {/* cursor dot */}
        <div
          className="absolute w-4 h-4 rounded-full border-2 border-white shadow pointer-events-none -translate-x-1/2 -translate-y-1/2"
          style={{ left: `${sat * 100}%`, top: `${(1 - val) * 100}%`, background: hex }}
        />
      </div>

      {/* Hue strip */}
      <div className="relative rounded-lg overflow-hidden" style={{ height: 20 }}>
        <canvas
          ref={hueRef}
          width={280}
          height={20}
          className="w-full h-full cursor-crosshair block"
          onClick={pickHue}
        />
        <div
          className="absolute top-0 bottom-0 w-3 rounded-sm border-2 border-white shadow pointer-events-none -translate-x-1/2"
          style={{ left: `${(hue / 360) * 100}%`, background: `hsl(${hue},100%,50%)` }}
        />
      </div>

      {/* Result row */}
      <div className="flex items-center gap-3">
        <div
          className="w-12 h-12 rounded-xl shadow-md shrink-0 border border-black/10"
          style={{ background: hex }}
        />
        <div className="flex-1 min-w-0">
          <p className="text-xl font-mono font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">
            {hex.toUpperCase()}
          </p>
          <p className="text-xs font-mono text-zinc-400 dark:text-zinc-500">
            rgb({r}, {g}, {b})
          </p>
        </div>
        <button
          onClick={copy}
          className={`px-3 py-2 rounded-xl text-xs font-medium transition-all duration-200 ${
            copied
              ? "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"
              : "bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700"
          }`}
        >
          {copied ? "✓ Copied" : "Copy"}
        </button>
      </div>
    </div>
  );
}

More in UI