Skip to content

Color Picker

Install this component from the Scintillar registry.

Click to edit:

Controls

Installation

npx shadcn@latest add @scintillar/color-picker

Source

"use client";

import { useCallback, useRef, useState, useMemo, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { PlusIcon, MinusIcon } from "lucide-react";
import {
  hexToRgb,
  hexToHsl,
  hexToCmyk,
  formatColor,
  type ColorFormat,
} from "@/lib/color-utils";
import {
  ColorSwatch,
  ColorValue,
  gradientToCss,
  checkerBg,
  checkerSize,
  checkerPosition,
  type GradientType,
  type GradientStop,
  type GradientValue,
} from "@/components/ui/color-swatch";

// ── Conversion helpers ───────────────────────────────────────────

function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); }
function isValidHex(v: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(v); }

function hslToHex(h: number, s: number, l: number): string {
  const sn = s / 100, ln = l / 100;
  const a = sn * Math.min(ln, 1 - ln);
  const f = (n: number) => { const k = (n + h / 30) % 12; return Math.round(255 * (ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1))).toString(16).padStart(2, "0"); };
  return `#${f(0)}${f(8)}${f(4)}`;
}
function rgbToHex(r: number, g: number, b: number): string { return `#${[r, g, b].map((v) => clamp(v, 0, 255).toString(16).padStart(2, "0")).join("")}`; }
function cmykToHex(c: number, m: number, y: number, k: number): string { return rgbToHex(Math.round(255 * (1 - c / 100) * (1 - k / 100)), Math.round(255 * (1 - m / 100) * (1 - k / 100)), Math.round(255 * (1 - y / 100) * (1 - k / 100))); }
function hsbToHex(h: number, s: number, b: number): string { const l = b * (1 - s / 200); const sl = l === 0 || l === 100 ? 0 : ((b - l) / Math.min(l, 100 - l)) * 100; return hslToHex(h, sl, l); }
function hexToHsb(hex: string): { h: number; s: number; b: number } { const { h, s, l } = hexToHsl(hex); const b = l + s * Math.min(l, 100 - l) / 100; const sb = b === 0 ? 0 : 200 * (1 - l / b); return { h, s: isNaN(sb) ? 0 : sb, b }; }

function makeId() { return Math.random().toString(36).slice(2, 8); }
function defaultGradient(hex = "#000000"): GradientValue {
  return { type: "linear", angle: 90, stops: [{ id: makeId(), color: hex, position: 0, opacity: 100 }, { id: makeId(), color: "#ffffff", position: 100, opacity: 100 }] };
}

// ── Subcomponents ────────────────────────────────────────────────

const STEP = 1;
const LARGE_STEP = 5;

function SatBrightCanvas({ hue, saturation, brightness, onChange }: { hue: number; saturation: number; brightness: number; onChange: (s: number, b: number) => void }) {
  const ref = useRef<HTMLDivElement>(null); const dragging = useRef(false);
  const update = useCallback((e: { clientX: number; clientY: number }) => { const rect = ref.current!.getBoundingClientRect(); onChange(clamp((e.clientX - rect.left) / rect.width, 0, 1) * 100, (1 - clamp((e.clientY - rect.top) / rect.height, 0, 1)) * 100); }, [onChange]);
  function onKeyDown(e: React.KeyboardEvent) {
    const step = e.shiftKey ? LARGE_STEP : STEP;
    if (e.key === "ArrowRight") { e.preventDefault(); onChange(clamp(saturation + step, 0, 100), brightness); }
    else if (e.key === "ArrowLeft") { e.preventDefault(); onChange(clamp(saturation - step, 0, 100), brightness); }
    else if (e.key === "ArrowUp") { e.preventDefault(); onChange(saturation, clamp(brightness + step, 0, 100)); }
    else if (e.key === "ArrowDown") { e.preventDefault(); onChange(saturation, clamp(brightness - step, 0, 100)); }
  }
  return (
    <div ref={ref} tabIndex={0} role="slider" aria-label="Saturation and brightness" aria-valuenow={Math.round(saturation)} aria-valuemin={0} aria-valuemax={100} aria-valuetext={`Saturation ${Math.round(saturation)}%, Brightness ${Math.round(brightness)}%`}
      className="relative h-40 rounded-md cursor-crosshair select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none" style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hue}, 100%, 50%))` }}
      onPointerDown={(e) => { dragging.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); update(e); }}
      onPointerMove={(e) => dragging.current && update(e)} onPointerUp={() => (dragging.current = false)} onKeyDown={onKeyDown}>
      <div className="absolute size-3.5 rounded-full border-2 border-white shadow-md -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${saturation}%`, top: `${100 - brightness}%` }} />
    </div>
  );
}

function HueSlider({ hue, onChange }: { hue: number; onChange: (h: number) => void }) {
  const ref = useRef<HTMLDivElement>(null); const dragging = useRef(false);
  const update = useCallback((e: { clientX: number }) => { onChange(clamp((e.clientX - ref.current!.getBoundingClientRect().left) / ref.current!.getBoundingClientRect().width, 0, 1) * 360); }, [onChange]);
  function onKeyDown(e: React.KeyboardEvent) {
    const step = e.shiftKey ? LARGE_STEP * 3.6 : STEP * 3.6;
    if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); onChange(clamp(hue + step, 0, 360)); }
    else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); onChange(clamp(hue - step, 0, 360)); }
  }
  return (
    <div ref={ref} tabIndex={0} role="slider" aria-label="Hue" aria-valuemin={0} aria-valuemax={360} aria-valuenow={Math.round(hue)}
      className="relative h-3 rounded-full cursor-pointer select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none" style={{ background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)" }}
      onPointerDown={(e) => { dragging.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); update(e); }}
      onPointerMove={(e) => dragging.current && update(e)} onPointerUp={() => (dragging.current = false)} onKeyDown={onKeyDown}>
      <div className="absolute top-1/2 size-4 rounded-full border-2 border-white shadow-md -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${(hue / 360) * 100}%`, background: `hsl(${hue}, 100%, 50%)` }} />
    </div>
  );
}

function OpacitySlider({ hex, opacity, onChange }: { hex: string; opacity: number; onChange: (o: number) => void }) {
  const ref = useRef<HTMLDivElement>(null); const dragging = useRef(false);
  const update = useCallback((e: { clientX: number }) => { onChange(Math.round(clamp((e.clientX - ref.current!.getBoundingClientRect().left) / ref.current!.getBoundingClientRect().width, 0, 1) * 100)); }, [onChange]);
  function onKeyDown(e: React.KeyboardEvent) {
    const step = e.shiftKey ? LARGE_STEP : STEP;
    if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); onChange(clamp(opacity + step, 0, 100)); }
    else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); onChange(clamp(opacity - step, 0, 100)); }
  }
  return (
    <div ref={ref} tabIndex={0} role="slider" aria-label="Opacity" aria-valuemin={0} aria-valuemax={100} aria-valuenow={opacity}
      className="relative h-3 rounded-full cursor-pointer select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none" style={{ backgroundImage: checkerBg, backgroundSize: checkerSize, backgroundPosition: checkerPosition }}
      onPointerDown={(e) => { dragging.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); update(e); }}
      onPointerMove={(e) => dragging.current && update(e)} onPointerUp={() => (dragging.current = false)} onKeyDown={onKeyDown}>
      <div className="absolute inset-0 rounded-full" style={{ background: `linear-gradient(to right, transparent, ${hex})` }} />
      <div className="absolute top-1/2 size-4 rounded-full border-2 border-white shadow-md -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${opacity}%`, background: hex, opacity: opacity / 100 }} />
    </div>
  );
}

function DraftNumberInput({ value, max, onChange, className }: { value: number; max: number; onChange: (v: number) => void; className?: string }) {
  const [draft, setDraft] = useState<string | null>(null);
  return <input type="number" min={0} max={max} value={draft ?? String(value)}
    onChange={(e) => { setDraft(e.target.value); const n = parseInt(e.target.value); if (!isNaN(n)) onChange(clamp(n, 0, max)); }}
    onFocus={() => setDraft(String(value))} onBlur={() => setDraft(null)} className={className} />;
}

function GradientStopBar({ stops, activeStopId, onSelect, onMove, gradientCss }: {
  stops: GradientStop[]; activeStopId: string; onSelect: (id: string) => void; onMove: (id: string, pos: number) => void; gradientCss: string;
}) {
  const barRef = useRef<HTMLDivElement>(null); const dragging = useRef<string | null>(null);
  function onKeyDown(e: React.KeyboardEvent) {
    if (!activeStopId) return;
    const stop = stops.find((s) => s.id === activeStopId);
    if (!stop) return;
    const step = e.shiftKey ? LARGE_STEP : STEP;
    if (e.key === "ArrowRight") { e.preventDefault(); onMove(activeStopId, clamp(stop.position + step, 0, 100)); }
    else if (e.key === "ArrowLeft") { e.preventDefault(); onMove(activeStopId, clamp(stop.position - step, 0, 100)); }
  }
  return (
    <div ref={barRef} tabIndex={0} role="slider" aria-label="Gradient stops" aria-valuenow={stops.find((s) => s.id === activeStopId)?.position ?? 0} aria-valuemin={0} aria-valuemax={100}
      className="relative h-6 rounded-md cursor-crosshair select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none" style={{ background: gradientCss }}
      onPointerMove={(e) => { if (!dragging.current || !barRef.current) return; const r = barRef.current.getBoundingClientRect(); onMove(dragging.current, Math.round(clamp(((e.clientX - r.left) / r.width) * 100, 0, 100))); }}
      onPointerUp={() => { dragging.current = null; }} onKeyDown={onKeyDown}>
      {stops.map((s) => (
        <div key={s.id} className={cn("absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab active:cursor-grabbing w-3 h-8 rounded-sm border-2 shadow-md transition-shadow", activeStopId === s.id ? "border-primary ring-2 ring-primary/30 z-10" : "border-white")}
          style={{ left: `${s.position}%`, background: s.color }}
          onPointerDown={(e) => { e.stopPropagation(); dragging.current = s.id; onSelect(s.id); (e.target as HTMLElement).setPointerCapture(e.pointerId); }} />
      ))}
    </div>
  );
}

function AngleDial({ angle, onChange }: { angle: number; onChange: (a: number) => void }) {
  const ref = useRef<HTMLDivElement>(null); const dragging = useRef(false);
  function update(e: { clientX: number; clientY: number }) { if (!ref.current) return; const r = ref.current.getBoundingClientRect(); let deg = Math.round(Math.atan2(e.clientY - r.top - r.height / 2, e.clientX - r.left - r.width / 2) * 180 / Math.PI + 90); if (deg < 0) deg += 360; onChange(deg % 360); }
  function onKeyDown(e: React.KeyboardEvent) {
    const step = e.shiftKey ? 15 : 5;
    if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); onChange((angle + step) % 360); }
    else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); onChange((angle - step + 360) % 360); }
  }
  return (
    <div ref={ref} tabIndex={0} role="slider" aria-label="Angle" aria-valuemin={0} aria-valuemax={360} aria-valuenow={angle}
      className="relative size-7 rounded-full border bg-muted/30 cursor-crosshair select-none shrink-0 focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none"
      onPointerDown={(e) => { dragging.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); update(e); }}
      onPointerMove={(e) => dragging.current && update(e)} onPointerUp={() => (dragging.current = false)} onKeyDown={onKeyDown}>
      <div className="absolute top-1/2 left-1/2 w-2.5 h-0.5 bg-foreground rounded-full origin-left" style={{ transform: `rotate(${angle - 90}deg) translateY(-50%)` }} />
    </div>
  );
}

const INPUT_CLS = "min-w-10 flex-1 text-xs font-mono bg-transparent border rounded px-1.5 py-1 outline-none text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";

// ── Mode tabs (shared between both popovers) ────────────────────

const MODES: { id: GradientType; label: string }[] = [
  { id: "solid", label: "Solid" },
  { id: "linear", label: "Linear" },
  { id: "radial", label: "Radial" },
  { id: "angular", label: "Angular" },
];

function ModeTabs({ active, onChange, enableGradient }: { active: GradientType; onChange: (t: GradientType) => void; enableGradient: boolean }) {
  const items = enableGradient ? MODES : MODES.slice(0, 1);
  if (items.length <= 1) return null;
  return (
    <div className="flex items-center rounded-md border text-xs">
      {items.map((t, i) => (
        <button key={t.id} onClick={() => onChange(t.id)}
          className={cn("flex-1 px-2 py-1 cursor-pointer transition-colors font-medium", i > 0 && "border-l", active === t.id ? "bg-accent" : "text-muted-foreground hover:bg-muted/50", i === 0 && "rounded-l-md", i === items.length - 1 && "rounded-r-md")}>
          {t.label}
        </button>
      ))}
    </div>
  );
}

// ── Floating popover shell ──────────────────────────────────────

/**
 * Floating popover portaled to document.body. Marks itself with data-color-picker-popover
 * so parent popovers can detect nested pickers in their outside-click handlers.
 */
function FloatingPopover({ triggerRef, triggerEl, children, offset, className }: {
  triggerRef?: React.RefObject<HTMLElement | null>; triggerEl?: HTMLElement | null; children: React.ReactNode; offset?: { x: number; y: number }; className?: string;
}) {
  const popRef = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    const el = triggerEl ?? triggerRef?.current; const pop = popRef.current;
    if (!el || !pop) return;
    const rect = el.getBoundingClientRect(); const popRect = pop.getBoundingClientRect();
    const ox = offset?.x ?? 0; const oy = offset?.y ?? 0;
    const alignRight = rect.left > window.innerWidth / 2;
    const above = rect.top > window.innerHeight / 2;
    pop.style.left = `${(alignRight ? rect.right - popRect.width : rect.left) + ox}px`;
    pop.style.top = `${(above ? rect.top - popRect.height - 4 : rect.bottom + 4) + oy}px`;
    pop.style.visibility = "visible";
  });

  return createPortal(
    <div ref={popRef} data-color-picker-popover className={cn("fixed z-50 rounded-lg border bg-popover p-3 shadow-lg", className)} style={{ visibility: "hidden" }}>
      {children}
    </div>,
    document.body,
  );
}

// ── Solid color picker content ──────────────────────────────────

interface SolidPickerProps {
  value: string;
  opacity?: number;
  format?: ColorFormat;
  onSave: (hex: string, opacity: number) => void;
  onCancel: () => void;
  /** Called on every change (drag, input) for live preview. */
  onChange?: (hex: string, opacity: number) => void;
  /** Hide save/cancel buttons (e.g., when used purely as live-only embedded picker). */
  hideActions?: boolean;
  cancelLabel?: string;
  saveLabel?: string;
}

function SolidPickerContent({ value, opacity: initialOpacity = 100, format: initialFormat = "hex", onSave, onCancel, onChange, hideActions = false, cancelLabel = "Cancel", saveLabel = "Save" }: SolidPickerProps) {
  const hsb = hexToHsb(value || "#000000");
  const [hue, setHue] = useState(hsb.h);
  const [sat, setSat] = useState(hsb.s);
  const [bright, setBright] = useState(hsb.b);
  const [opacity, setOpacity] = useState(initialOpacity);
  const [mode, setMode] = useState<ColorFormat>(initialFormat);
  const [hexDraft, setHexDraft] = useState<string | null>(null);

  const currentHex = hsbToHex(hue, sat, bright);
  const hexDisplayed = hexDraft ?? currentHex.toUpperCase();
  const onChangeRef = useRef(onChange);
  useLayoutEffect(() => { onChangeRef.current = onChange; });

  // Fire live onChange after every color/opacity mutation
  function notify(hex: string, op: number) { onChangeRef.current?.(hex, op); }
  function setHueAndNotify(h: number) { setHue(h); notify(hsbToHex(h, sat, bright), opacity); }
  function setSatBrightAndNotify(s: number, b: number) { setSat(s); setBright(b); notify(hsbToHex(hue, s, b), opacity); }
  function setOpacityAndNotify(o: number) { setOpacity(o); notify(currentHex, o); }
  function setFromHex(hex: string) { if (!isValidHex(hex)) return; const h = hexToHsb(hex); setHue(h.h); setSat(h.s); setBright(h.b); notify(hex, opacity); }

  const { r, g, b: blue } = hexToRgb(currentHex);
  const { h: hslH, s: hslS, l: hslL } = hexToHsl(currentHex);
  const { c: cmykC, m: cmykM, y: cmykY, k: cmykK } = hexToCmyk(currentHex);
  const fmtModes: ColorFormat[] = ["hex", "rgb", "hsl", "cmyk"];

  function renderInputs() {
    switch (mode) {
      case "hex": return (
        <div className="flex items-center gap-1.5"><span className="text-xs text-muted-foreground shrink-0">HEX</span>
          <input className={cn(INPUT_CLS, "flex-1 text-left", hexDraft !== null && !isValidHex(hexDraft.startsWith("#") ? hexDraft : `#${hexDraft}`) && "border-destructive text-destructive")}
            value={hexDisplayed} onChange={(e) => { setHexDraft(e.target.value); const v = e.target.value.startsWith("#") ? e.target.value : `#${e.target.value}`; if (isValidHex(v)) setFromHex(v.toLowerCase()); }}
            onFocus={() => setHexDraft(currentHex.toUpperCase())} onBlur={() => setHexDraft(null)} /></div>
      );
      case "rgb": return (<div className="flex gap-3">{[{ l: "R", v: r, fn: (n: number) => rgbToHex(n, g, blue) }, { l: "G", v: g, fn: (n: number) => rgbToHex(r, n, blue) }, { l: "B", v: blue, fn: (n: number) => rgbToHex(r, g, n) }].map(({ l, v, fn }) => (<div key={l} className="flex items-center gap-1.5 flex-1"><span className="text-xs text-muted-foreground">{l}</span><DraftNumberInput value={v} max={255} onChange={(n) => setFromHex(fn(n))} className={INPUT_CLS} /></div>))}</div>);
      case "hsl": return (<div className="flex gap-3">{[{ l: "H", v: hslH, max: 360, fn: (n: number) => hslToHex(n, hslS, hslL) }, { l: "S", v: hslS, max: 100, fn: (n: number) => hslToHex(hslH, n, hslL) }, { l: "L", v: hslL, max: 100, fn: (n: number) => hslToHex(hslH, hslS, n) }].map(({ l, v, max, fn }) => (<div key={l} className="flex items-center gap-1.5 flex-1"><span className="text-xs text-muted-foreground">{l}</span><DraftNumberInput value={v} max={max} onChange={(n) => setFromHex(fn(n))} className={INPUT_CLS} /></div>))}</div>);
      case "cmyk": return (<div className="flex gap-3">{[{ l: "C", v: cmykC, fn: (n: number) => cmykToHex(n, cmykM, cmykY, cmykK) }, { l: "M", v: cmykM, fn: (n: number) => cmykToHex(cmykC, n, cmykY, cmykK) }, { l: "Y", v: cmykY, fn: (n: number) => cmykToHex(cmykC, cmykM, n, cmykK) }, { l: "K", v: cmykK, fn: (n: number) => cmykToHex(cmykC, cmykM, cmykY, n) }].map(({ l, v, fn }) => (<div key={l} className="flex items-center gap-1.5 flex-1"><span className="text-xs text-muted-foreground">{l}</span><DraftNumberInput value={v} max={100} onChange={(n) => setFromHex(fn(n))} className={INPUT_CLS} /></div>))}</div>);
    }
  }

  return (
    <div className="space-y-3">
      <SatBrightCanvas hue={hue} saturation={sat} brightness={bright} onChange={setSatBrightAndNotify} />
      <HueSlider hue={hue} onChange={setHueAndNotify} />
      <OpacitySlider hex={currentHex} opacity={opacity} onChange={setOpacityAndNotify} />
      <div className="flex items-center gap-1.5">
        <ColorSwatch hex={currentHex} opacity={opacity} size="size-7" className="cursor-default" />
        <div className="flex items-center rounded-md border text-xs w-full">
          {fmtModes.map((m, i) => (
            <button key={m} onClick={() => setMode(m)} className={cn("flex-1 px-1.5 py-1 cursor-pointer transition-colors uppercase font-medium", i > 0 && "border-l", mode === m ? "bg-accent" : "text-muted-foreground hover:bg-muted/50", i === 0 && "rounded-l-md", i === fmtModes.length - 1 && "rounded-r-md")}>{m}</button>
          ))}
        </div>
        <DraftNumberInput value={opacity} max={100} onChange={setOpacityAndNotify} className={cn(INPUT_CLS, "w-12 shrink-0")} />
        <span className="text-xs text-muted-foreground shrink-0">%</span>
      </div>
      {renderInputs()}
      {!hideActions && (
        <div className="flex items-center justify-end gap-2">
          <Button variant="outline" size="sm" onClick={onCancel}>{cancelLabel}</Button>
          <Button size="sm" onClick={() => onSave(currentHex, opacity)}>{saveLabel}</Button>
        </div>
      )}
    </div>
  );
}

// ── Gradient picker content ─────────────────────────────────────

interface GradientPickerContentProps {
  gradient: GradientValue;
  onChange: (g: GradientValue) => void;
  onSave: (g: GradientValue) => void;
  onCancel: () => void;
  format?: ColorFormat;
  cancelLabel?: string;
  saveLabel?: string;
}

function GradientPickerContent({ gradient, onChange, onSave, onCancel, format, cancelLabel = "Cancel", saveLabel = "Save" }: GradientPickerContentProps) {
  const [activeStopId, setActiveStopId] = useState(gradient.stops[0]?.id ?? "");
  const [editingStopId, setEditingStopId] = useState<string | null>(null);
  // Store original color/opacity when opening the nested picker so Cancel can revert
  const originalStopRef = useRef<{ color: string; opacity: number } | null>(null);
  // Use a ref callback map so every swatch always has a ref
  const swatchRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
  const editingStop = gradient.stops.find((s) => s.id === editingStopId);
  // eslint-disable-next-line react-hooks/refs -- ref lookup needed to position nested popover relative to swatch
  const editSwatchEl = editingStopId ? swatchRefs.current.get(editingStopId) ?? null : null;
  // Force a re-render after setting editingStopId so the ref is available
  const [, forceUpdate] = useState(0);

  function openStopEditor(stopId: string) {
    const stop = gradient.stops.find((s) => s.id === stopId);
    if (stop) originalStopRef.current = { color: stop.color, opacity: stop.opacity };
    setActiveStopId(stopId);
    setEditingStopId(editingStopId === stopId ? null : stopId);
    forceUpdate((n) => n + 1);
  }

  function cancelStopEditor() {
    if (editingStopId && originalStopRef.current) {
      updateStop(editingStopId, originalStopRef.current);
    }
    originalStopRef.current = null;
    setEditingStopId(null);
  }

  function applyStopEditor() {
    originalStopRef.current = null;
    setEditingStopId(null);
  }

  function updateStop(id: string, patch: Partial<GradientStop>) {
    onChange({ ...gradient, stops: gradient.stops.map((s) => s.id === id ? { ...s, ...patch } : s) });
  }

  function addStop() {
    const sorted = [...gradient.stops].sort((a, b) => a.position - b.position);
    let bestGap = 0, bestPos = 50;
    for (let i = 0; i < sorted.length - 1; i++) { const gap = sorted[i + 1].position - sorted[i].position; if (gap > bestGap) { bestGap = gap; bestPos = Math.round(sorted[i].position + gap / 2); } }
    const newStop: GradientStop = { id: makeId(), color: sorted[0]?.color ?? "#888888", position: bestPos, opacity: 100 };
    onChange({ ...gradient, stops: [...gradient.stops, newStop] });
    setActiveStopId(newStop.id);
  }

  function removeStop(id: string) {
    if (gradient.stops.length <= 2) return;
    const remaining = gradient.stops.filter((s) => s.id !== id);
    onChange({ ...gradient, stops: remaining });
    if (activeStopId === id) setActiveStopId(remaining[0].id);
    if (editingStopId === id) setEditingStopId(null);
  }

  const previewCss = useMemo(() => {
    const stopsCss = [...gradient.stops].sort((a, b) => a.position - b.position)
      .map((s) => { const { r, g, b } = hexToRgb(s.color); return `rgba(${r}, ${g}, ${b}, ${s.opacity / 100}) ${s.position}%`; }).join(", ");
    return `linear-gradient(to right, ${stopsCss})`;
  }, [gradient.stops]);

  return (
    <div className="space-y-3">
      {/* Gradient preview */}
      <div className="h-24 rounded-md overflow-hidden" style={{ backgroundImage: checkerBg, backgroundSize: checkerSize, backgroundPosition: checkerPosition }}>
        <div className="w-full h-full" style={{ background: gradientToCss(gradient) }} />
      </div>

      {/* Stop bar */}
      <GradientStopBar stops={gradient.stops} activeStopId={activeStopId} onSelect={setActiveStopId}
        onMove={(id, pos) => updateStop(id, { position: pos })} gradientCss={previewCss} />

      {/* Type + angle */}
      {(gradient.type === "linear" || gradient.type === "angular") && (
        <div className="flex items-center gap-1.5">
          <AngleDial angle={gradient.angle} onChange={(a) => onChange({ ...gradient, angle: a })} />
          <DraftNumberInput value={gradient.angle} max={360} onChange={(a) => onChange({ ...gradient, angle: a })} className={cn(INPUT_CLS, "w-10")} />
          <span className="text-[10px] text-muted-foreground">°</span>
        </div>
      )}

      {/* Stop list */}
      <div className="space-y-1">
        <div className="flex items-center justify-between">
          <span className="text-xs font-medium text-muted-foreground">Stops</span>
          <button onClick={addStop} className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer"><PlusIcon className="size-3.5" /></button>
        </div>
        <div className="space-y-0.5">
          {[...gradient.stops].sort((a, b) => a.position - b.position).map((stop) => (
            <div key={stop.id} className={cn("flex items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors", activeStopId === stop.id ? "bg-muted/50" : "hover:bg-muted/30")}>
              <DraftNumberInput value={stop.position} max={100} onChange={(p) => updateStop(stop.id, { position: p })} className={cn(INPUT_CLS, "w-10 shrink-0")} />
              <span className="text-[10px] text-muted-foreground shrink-0">%</span>
              <button
                ref={(el) => { if (el) swatchRefs.current.set(stop.id, el); else swatchRefs.current.delete(stop.id); }}
                onClick={() => openStopEditor(stop.id)}
                className={cn("size-5 rounded shrink-0 cursor-pointer transition-shadow", editingStopId === stop.id && "ring-2 ring-primary/40")}
                style={{ background: stop.color }} />
              <input value={stop.color.replace("#", "").toUpperCase()} className={cn(INPUT_CLS, "w-16 text-left")}
                onChange={(e) => { const v = `#${e.target.value.replace("#", "")}`; if (isValidHex(v)) updateStop(stop.id, { color: v.toLowerCase() }); }} />
              <DraftNumberInput value={stop.opacity} max={100} onChange={(o) => updateStop(stop.id, { opacity: o })} className={cn(INPUT_CLS, "w-10 shrink-0")} />
              <span className="text-[10px] text-muted-foreground shrink-0">%</span>
              {gradient.stops.length > 2 && (
                <button onClick={() => removeStop(stop.id)} className="p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive cursor-pointer shrink-0"><MinusIcon className="size-3" /></button>
              )}
            </div>
          ))}
        </div>
      </div>

      {/* Actions */}
      <div className="flex items-center justify-end gap-2">
        <Button variant="outline" size="sm" onClick={onCancel}>{cancelLabel}</Button>
        <Button size="sm" onClick={() => onSave(gradient)}>{saveLabel}</Button>
      </div>

      {/* Nested color picker popover for editing a stop's color */}
      {editingStop && editSwatchEl && (
        <FloatingPopover triggerEl={editSwatchEl} offset={{ x: 0, y: 0 }} className="z-60">
          <div className="w-64 space-y-3">
            <SolidPickerContent
              value={editingStop.color}
              opacity={editingStop.opacity}
              format={format}
              cancelLabel="Cancel"
              saveLabel="Apply"
              onChange={(hex, opacity) => updateStop(editingStop.id, { color: hex, opacity })}
              onCancel={cancelStopEditor}
              onSave={applyStopEditor}
            />
          </div>
        </FloatingPopover>
      )}
    </div>
  );
}

// ── Main ColorPicker ────────────────────────────────────────────

interface ColorPickerProps {
  /** Current hex color value (e.g. "#6366f1"). */
  value: string;
  /** Current opacity (0–100). */
  opacity?: number;
  /** Initial gradient configuration. Only used when enableGradient is true. */
  gradient?: GradientValue;
  /** Called when the user saves a color selection. */
  onSave: (hex: string, opacity: number, gradient?: GradientValue) => void;
  /** Called when the user cancels editing. */
  onCancel: () => void;
  /** Color format for display (hex, rgb, hsl, cmyk). */
  format?: ColorFormat;
  /** Enable gradient mode tabs (linear, radial, angular). */
  enableGradient?: boolean;
  /** Label for the cancel button. */
  cancelLabel?: string;
  /** Label for the save button. */
  saveLabel?: string;
}

export { ColorSwatch, ColorValue, gradientToCss } from "@/components/ui/color-swatch";
export type { GradientType, GradientStop, GradientValue } from "@/components/ui/color-swatch";

/**
 * Full-featured color picker with support for solid colors and gradients.
 * Provides an HSB saturation/brightness area, hue slider, opacity slider,
 * and numeric inputs for multiple color formats (hex, RGB, HSL, CMYK).
 */
export function ColorPicker({ value, opacity: initialOpacity = 100, gradient: initialGradient, onSave, onCancel, format = "hex", enableGradient = false, cancelLabel = "Cancel", saveLabel = "Save" }: ColorPickerProps) {
  const [gradType, setGradType] = useState<GradientType>(initialGradient?.type ?? "solid");
  const [gradient, setGradient] = useState<GradientValue>(initialGradient ?? defaultGradient(value));

  function handleModeChange(type: GradientType) {
    if (type === "solid" && gradType !== "solid") {
      setGradType("solid");
    } else if (type !== "solid") {
      if (gradType === "solid") {
        setGradient({ ...defaultGradient(value), type });
      } else {
        setGradient({ ...gradient, type });
      }
      setGradType(type);
    }
  }

  const isGradient = gradType !== "solid";

  return (
    <div className="w-64 space-y-3">
      <ModeTabs active={gradType} onChange={handleModeChange} enableGradient={enableGradient} />

      {isGradient ? (
        <GradientPickerContent
          gradient={gradient}
          onChange={setGradient}
          onSave={(g) => onSave(g.stops[0]?.color ?? value, 100, g)}
          onCancel={onCancel}
          format={format}
          cancelLabel={cancelLabel}
          saveLabel={saveLabel}
        />
      ) : (
        <SolidPickerContent
          value={value}
          opacity={initialOpacity}
          format={format}
          onSave={(hex, opacity) => onSave(hex, opacity)}
          onCancel={onCancel}
          cancelLabel={cancelLabel}
          saveLabel={saveLabel}
        />
      )}
    </div>
  );
}