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.
npx shadcn@latest add @paragon/color-picker"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>
);
}