A color picker is useful in design tools, theme builders, and content editors. Here is how to build one from scratch with HSL manipulation.
Color Conversion Utilities
// lib/color-utils.ts
export interface HSL {
h: number; // 0-360
s: number; // 0-100
l: number; // 0-100
}
export function hslToHex({ h, s, l }: HSL): string {
const sNorm = s / 100;
const lNorm = l / 100;
const a = sNorm * Math.min(lNorm, 1 - lNorm);
function f(n: number): string {
const k = (n + h / 30) % 12;
const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, "0");
}
return `#${f(0)}${f(8)}${f(4)}`;
}
export function hexToHsl(hex: string): HSL {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return { h: 0, s: 0, l: 0 };
const r = parseInt(result[1], 16) / 255;
const g = parseInt(result[2], 16) / 255;
const b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
export function isValidHex(hex: string): boolean {
return /^#?([a-f\d]{3}|[a-f\d]{6})$/i.test(hex);
}
The Color Picker Component
"use client";
import { useCallback, useRef, useState } from "react";
import { hslToHex, hexToHsl, isValidHex, type HSL } from "@/lib/color-utils";
interface ColorPickerProps {
value?: string;
onChange?: (hex: string) => void;
presets?: string[];
}
const DEFAULT_PRESETS = [
"#ef4444", "#f97316", "#eab308", "#22c55e",
"#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
"#000000", "#6b7280", "#ffffff",
];
export function ColorPicker({
value = "#3b82f6",
onChange,
presets = DEFAULT_PRESETS,
}: ColorPickerProps) {
const [hsl, setHsl] = useState<HSL>(hexToHsl(value));
const [hexInput, setHexInput] = useState(value);
const [copied, setCopied] = useState(false);
const hex = hslToHex(hsl);
const updateColor = useCallback(
(newHsl: HSL) => {
setHsl(newHsl);
const newHex = hslToHex(newHsl);
setHexInput(newHex);
onChange?.(newHex);
},
[onChange]
);
const handleHexChange = useCallback(
(input: string) => {
setHexInput(input);
const normalized = input.startsWith("#") ? input : `#${input}`;
if (isValidHex(normalized)) {
const newHsl = hexToHsl(normalized);
setHsl(newHsl);
onChange?.(normalized);
}
},
[onChange]
);
const copyToClipboard = useCallback(async () => {
await navigator.clipboard.writeText(hex);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [hex]);
return (
<div className="w-72 p-4 border rounded-lg bg-background shadow-lg space-y-4">
{/* Saturation/Lightness Gradient */}
<SaturationPicker hsl={hsl} onChange={updateColor} />
{/* Hue Slider */}
<div>
<label className="text-xs text-muted-foreground">Hue</label>
<input
type="range"
min={0}
max={360}
value={hsl.h}
onChange={(e) => updateColor({ ...hsl, h: Number(e.target.value) })}
className="w-full h-3 rounded-full appearance-none cursor-pointer"
style={{
background:
"linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)",
}}
/>
</div>
{/* Lightness Slider */}
<div>
<label className="text-xs text-muted-foreground">Lightness</label>
<input
type="range"
min={0}
max={100}
value={hsl.l}
onChange={(e) => updateColor({ ...hsl, l: Number(e.target.value) })}
className="w-full h-3 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #000, hsl(${hsl.h}, ${hsl.s}%, 50%), #fff)`,
}}
/>
</div>
{/* Hex Input and Preview */}
<div className="flex items-center gap-2">
<div
className="h-10 w-10 rounded-md border shrink-0"
style={{ backgroundColor: hex }}
/>
<div className="flex-1 relative">
<input
type="text"
value={hexInput}
onChange={(e) => handleHexChange(e.target.value)}
maxLength={7}
className="w-full px-3 py-2 border rounded-md text-sm font-mono"
aria-label="Hex color value"
/>
</div>
<button
onClick={copyToClipboard}
className="px-3 py-2 border rounded-md text-sm hover:bg-muted"
aria-label="Copy color value"
>
{copied ? "Done" : "Copy"}
</button>
</div>
{/* HSL Values */}
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-xs text-muted-foreground">H</label>
<input
type="number"
min={0}
max={360}
value={hsl.h}
onChange={(e) => updateColor({ ...hsl, h: Number(e.target.value) })}
className="w-full px-2 py-1 border rounded text-sm text-center"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">S</label>
<input
type="number"
min={0}
max={100}
value={hsl.s}
onChange={(e) => updateColor({ ...hsl, s: Number(e.target.value) })}
className="w-full px-2 py-1 border rounded text-sm text-center"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">L</label>
<input
type="number"
min={0}
max={100}
value={hsl.l}
onChange={(e) => updateColor({ ...hsl, l: Number(e.target.value) })}
className="w-full px-2 py-1 border rounded text-sm text-center"
/>
</div>
</div>
{/* Presets */}
{presets.length > 0 && (
<div>
<label className="text-xs text-muted-foreground">Presets</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{presets.map((preset) => (
<button
key={preset}
onClick={() => {
setHexInput(preset);
const newHsl = hexToHsl(preset);
updateColor(newHsl);
}}
className={`h-6 w-6 rounded-md border-2 ${
hex.toLowerCase() === preset.toLowerCase()
? "border-primary ring-2 ring-primary/30"
: "border-transparent hover:border-muted-foreground/30"
}`}
style={{ backgroundColor: preset }}
aria-label={`Select color ${preset}`}
/>
))}
</div>
</div>
)}
</div>
);
}
Saturation Picker Canvas
function SaturationPicker({
hsl,
onChange,
}: {
hsl: HSL;
onChange: (hsl: HSL) => void;
}) {
const canvasRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
isDragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
updateFromPointer(e);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[hsl.h, onChange]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging.current) return;
updateFromPointer(e);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[hsl.h, onChange]
);
const handlePointerUp = useCallback(() => {
isDragging.current = false;
}, []);
function updateFromPointer(e: React.PointerEvent) {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const s = Math.round((x / rect.width) * 100);
const l = Math.round(100 - (y / rect.height) * 100);
onChange({ ...hsl, s, l });
}
// Position of the selector circle
const selectorX = `${hsl.s}%`;
const selectorY = `${100 - hsl.l}%`;
return (
<div
ref={canvasRef}
className="relative h-40 w-full rounded-md cursor-crosshair touch-none"
style={{
background: `
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, hsl(${hsl.h}, 100%, 50%))
`,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<div
className="absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md pointer-events-none"
style={{
left: selectorX,
top: selectorY,
backgroundColor: hslToHex(hsl),
}}
/>
</div>
);
}
Usage
"use client";
import { useState } from "react";
import { ColorPicker } from "@/components/ColorPicker";
export default function ThemeBuilder() {
const [primaryColor, setPrimaryColor] = useState("#3b82f6");
return (
<div>
<h2 className="text-lg font-semibold mb-4">Choose Primary Color</h2>
<ColorPicker value={primaryColor} onChange={setPrimaryColor} />
<div
className="mt-4 px-6 py-3 rounded-md text-white text-center"
style={{ backgroundColor: primaryColor }}
>
Preview Button
</div>
</div>
);
}
Need Custom UI Components?
We design and build polished interactive components for web applications. Contact us to create a custom component library.