All componentsform
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.jsonnpm dependencies: lucide-react
Props
ColorPickerProps
| Prop | Type | Required |
|---|---|---|
| value | string | Yes |
| onChange | (value: string) => void | Yes |
| alpha | boolean | No |
| presets | string[] | No |
| className | string | No |
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>
);
}