Color Picker
A color picker with OKLCH, HSL, RGB, and HEX support, plus Tailwind CSS presets.
registry/new-york/blocks/color-picker/color-picker.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/registry/new-york/blocks/input/input";
// =============================================================================
// Types
// =============================================================================
type OklchColor = { l: number; c: number; h: number; a: number };
type ColorFormat = "oklch" | "hex" | "hsl" | "rgb";
type PresetColor = { label: string; value: string };
type ColorPickerProps = {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
format?: ColorFormat;
showAlpha?: boolean;
presets?: "tailwind" | PresetColor[];
className?: string;
};
// =============================================================================
// Color Conversion Utilities
// =============================================================================
function oklchToOklab(l: number, c: number, h: number) {
const hRad = (h * Math.PI) / 180;
return { L: l, a: c * Math.cos(hRad), b: c * Math.sin(hRad) };
}
function oklabToOklch(L: number, a: number, b: number) {
const c = Math.sqrt(a * a + b * b);
let h = (Math.atan2(b, a) * 180) / Math.PI;
if (h < 0) h += 360;
return { l: L, c, h };
}
function oklabToLinearRgb(L: number, a: number, b: number) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
return {
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
};
}
function linearRgbToOklab(r: number, g: number, b: number) {
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
return {
L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
};
}
function linearToSrgb(c: number): number {
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
}
function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function oklchToRgb(l: number, c: number, h: number): [number, number, number] {
const lab = oklchToOklab(l, c, h);
const lin = oklabToLinearRgb(lab.L, lab.a, lab.b);
return [
Math.round(clamp01(linearToSrgb(lin.r)) * 255),
Math.round(clamp01(linearToSrgb(lin.g)) * 255),
Math.round(clamp01(linearToSrgb(lin.b)) * 255),
];
}
function rgbToOklch(r: number, g: number, b: number) {
const lab = linearRgbToOklab(srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255));
return oklabToOklch(lab.L, lab.a, lab.b);
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const r1 = r / 255,
g1 = g / 255,
b1 = b / 255;
const max = Math.max(r1, g1, b1),
min = Math.min(r1, g1, b1);
const d = max - min,
l = (max + min) / 2;
if (d === 0) return [0, 0, l * 100];
const s = l > 0.5 ? d / (2 - max - min) : d / (max - min);
let h = 0;
if (max === r1) h = ((g1 - b1) / d + (g1 < b1 ? 6 : 0)) / 6;
else if (max === g1) h = ((b1 - r1) / d + 2) / 6;
else h = ((r1 - g1) / d + 4) / 6;
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const h1 = h / 360,
s1 = s / 100,
l1 = l / 100;
if (s1 === 0) {
const v = Math.round(l1 * 255);
return [v, v, v];
}
const hue2rgb = (p: number, q: number, t: number) => {
let t1 = t;
if (t1 < 0) t1 += 1;
if (t1 > 1) t1 -= 1;
if (t1 < 1 / 6) return p + (q - p) * 6 * t1;
if (t1 < 1 / 2) return q;
if (t1 < 2 / 3) return p + (q - p) * (2 / 3 - t1) * 6;
return p;
};
const q = l1 < 0.5 ? l1 * (1 + s1) : l1 + s1 - l1 * s1;
const p = 2 * l1 - q;
return [
Math.round(hue2rgb(p, q, h1 + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h1) * 255),
Math.round(hue2rgb(p, q, h1 - 1 / 3) * 255),
];
}
function rgbToHex(r: number, g: number, b: number, a?: number): string {
const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
if (a !== undefined && a < 1)
return (
hex +
Math.round(a * 255)
.toString(16)
.padStart(2, "0")
);
return hex;
}
function hexToRgb(hex: string) {
let h = hex.replace(/^#/, "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length === 4) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
function isInGamut(r: number, g: number, b: number) {
return r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255;
}
function gamutMapOklch(l: number, c: number, h: number): [number, number, number] {
let rgb = oklchToRgb(l, c, h);
if (isInGamut(rgb[0], rgb[1], rgb[2])) return rgb;
let lo = 0,
hi = c;
for (let i = 0; i < 20; i++) {
const mid = (lo + hi) / 2;
rgb = oklchToRgb(l, mid, h);
if (isInGamut(rgb[0], rgb[1], rgb[2])) lo = mid;
else hi = mid;
}
return oklchToRgb(l, lo, h);
}
function parseColor(input: string): OklchColor {
const s = input.trim().toLowerCase();
const oklchMatch = s.match(
/oklch\(\s*([\d.]+)(?:%?)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+%?))?\s*\)/
);
if (oklchMatch) {
let a = 1;
if (oklchMatch[4])
a = oklchMatch[4].endsWith("%") ? parseFloat(oklchMatch[4]) / 100 : parseFloat(oklchMatch[4]);
return {
l: parseFloat(oklchMatch[1]),
c: parseFloat(oklchMatch[2]),
h: parseFloat(oklchMatch[3]),
a,
};
}
const hslMatch = s.match(
/hsla?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)/
);
if (hslMatch) {
const [r, g, b] = hslToRgb(
parseFloat(hslMatch[1]),
parseFloat(hslMatch[2]),
parseFloat(hslMatch[3])
);
let a = 1;
if (hslMatch[4])
a = hslMatch[4].endsWith("%") ? parseFloat(hslMatch[4]) / 100 : parseFloat(hslMatch[4]);
return { ...rgbToOklch(r, g, b), a };
}
const rgbMatch = s.match(
/rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*(?:[,/]\s*([\d.]+%?))?\s*\)/
);
if (rgbMatch) {
let a = 1;
if (rgbMatch[4])
a = rgbMatch[4].endsWith("%") ? parseFloat(rgbMatch[4]) / 100 : parseFloat(rgbMatch[4]);
return {
...rgbToOklch(parseFloat(rgbMatch[1]), parseFloat(rgbMatch[2]), parseFloat(rgbMatch[3])),
a,
};
}
if (s.startsWith("#")) {
const { r, g, b, a } = hexToRgb(s);
return { ...rgbToOklch(r, g, b), a };
}
return { l: 0.5, c: 0, h: 0, a: 1 };
}
function formatColor(color: OklchColor, format: ColorFormat): string {
const { l, c, h, a } = color;
switch (format) {
case "oklch": {
const ls = round4(l),
cs = round4(c),
hs = round2(h);
return a < 1 ? `oklch(${ls} ${cs} ${hs} / ${round2(a)})` : `oklch(${ls} ${cs} ${hs})`;
}
case "hex": {
const [r, g, b] = gamutMapOklch(l, c, h);
return rgbToHex(r, g, b, a < 1 ? a : undefined);
}
case "rgb": {
const [r, g, b] = gamutMapOklch(l, c, h);
return a < 1 ? `rgba(${r}, ${g}, ${b}, ${round2(a)})` : `rgb(${r}, ${g}, ${b})`;
}
case "hsl": {
const [r, g, b] = gamutMapOklch(l, c, h);
const [hh, ss, ll] = rgbToHsl(r, g, b);
return a < 1 ? `hsla(${hh}, ${ss}%, ${ll}%, ${round2(a)})` : `hsl(${hh}, ${ss}%, ${ll}%)`;
}
}
}
function clamp01(v: number) {
return Math.max(0, Math.min(1, v));
}
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
function round4(v: number) {
return Math.round(v * 10000) / 10000;
}
function round2(v: number) {
return Math.round(v * 100) / 100;
}
// =============================================================================
// Tailwind CSS v4 Default Palette (OKLCH)
// =============================================================================
type TwPaletteEntry = { name: string; shades: Record<number, [number, number, number]> };
const TAILWIND_PALETTE: TwPaletteEntry[] = [
{
name: "Red",
shades: {
50: [0.9705, 0.0129, 17.38],
100: [0.9356, 0.0309, 17.72],
200: [0.8845, 0.0593, 18.33],
300: [0.8077, 0.1035, 19.57],
400: [0.7106, 0.1661, 22.22],
500: [0.6368, 0.2078, 25.33],
600: [0.5771, 0.2152, 27.33],
700: [0.5054, 0.1905, 27.52],
800: [0.4437, 0.1613, 26.9],
900: [0.3958, 0.1331, 25.72],
950: [0.2575, 0.0886, 26.04],
},
},
{
name: "Orange",
shades: {
50: [0.9796, 0.0158, 73.68],
100: [0.9542, 0.0372, 75.16],
200: [0.9015, 0.073, 70.7],
300: [0.8366, 0.1165, 66.29],
400: [0.7576, 0.159, 55.93],
500: [0.7049, 0.1867, 47.6],
600: [0.6461, 0.1943, 41.12],
700: [0.5534, 0.1739, 38.4],
800: [0.4698, 0.143, 37.3],
900: [0.4084, 0.1165, 38.17],
950: [0.2659, 0.0762, 36.26],
},
},
{
name: "Amber",
shades: {
50: [0.9869, 0.0214, 95.28],
100: [0.9619, 0.059, 95.62],
200: [0.9243, 0.1151, 95.75],
300: [0.879, 0.1534, 91.61],
400: [0.8369, 0.1644, 84.43],
500: [0.7686, 0.1647, 70.08],
600: [0.6658, 0.1574, 58.32],
700: [0.5553, 0.1455, 49],
800: [0.4732, 0.1247, 46.2],
900: [0.4137, 0.1054, 45.9],
950: [0.2791, 0.0742, 45.64],
},
},
{
name: "Yellow",
shades: {
50: [0.9873, 0.0262, 102.21],
100: [0.9729, 0.0693, 103.19],
200: [0.9451, 0.1243, 101.54],
300: [0.9052, 0.1657, 98.11],
400: [0.8606, 0.1731, 91.94],
500: [0.7952, 0.1617, 86.05],
600: [0.6806, 0.1423, 75.83],
700: [0.5538, 0.1207, 66.44],
800: [0.4762, 0.1034, 61.91],
900: [0.421, 0.0897, 57.71],
950: [0.2857, 0.0639, 53.81],
},
},
{
name: "Lime",
shades: {
50: [0.9857, 0.0311, 120.76],
100: [0.9669, 0.0659, 122.33],
200: [0.9382, 0.1217, 124.32],
300: [0.8972, 0.1786, 126.67],
400: [0.8493, 0.2073, 128.85],
500: [0.7681, 0.2044, 130.85],
600: [0.6482, 0.1754, 131.68],
700: [0.5322, 0.1405, 131.59],
800: [0.4528, 0.1129, 130.93],
900: [0.405, 0.0956, 131.06],
950: [0.2741, 0.0688, 132.11],
},
},
{
name: "Green",
shades: {
50: [0.9819, 0.0181, 155.83],
100: [0.9624, 0.0434, 156.74],
200: [0.925, 0.0806, 155.99],
300: [0.8712, 0.1363, 154.45],
400: [0.8003, 0.1821, 151.71],
500: [0.7227, 0.1921, 149.58],
600: [0.6271, 0.1699, 149.21],
700: [0.5273, 0.1371, 150.07],
800: [0.4479, 0.1083, 151.33],
900: [0.3925, 0.0896, 152.54],
950: [0.2664, 0.0628, 152.93],
},
},
{
name: "Emerald",
shades: {
50: [0.9793, 0.0207, 166.11],
100: [0.9505, 0.0507, 163.05],
200: [0.9049, 0.0895, 164.15],
300: [0.8452, 0.1299, 164.98],
400: [0.7729, 0.1535, 163.22],
500: [0.6959, 0.1491, 162.48],
600: [0.596, 0.1274, 163.23],
700: [0.5081, 0.1049, 165.61],
800: [0.4318, 0.0865, 166.91],
900: [0.378, 0.073, 168.94],
950: [0.2621, 0.0487, 172.55],
},
},
{
name: "Teal",
shades: {
50: [0.9836, 0.0142, 180.72],
100: [0.9527, 0.0498, 180.8],
200: [0.91, 0.0927, 180.43],
300: [0.8549, 0.1251, 181.07],
400: [0.7845, 0.1325, 181.91],
500: [0.7038, 0.123, 182.5],
600: [0.6002, 0.1038, 184.7],
700: [0.5109, 0.0861, 186.39],
800: [0.437, 0.0705, 188.22],
900: [0.3861, 0.059, 188.42],
950: [0.2773, 0.0447, 192.52],
},
},
{
name: "Cyan",
shades: {
50: [0.9841, 0.0189, 200.87],
100: [0.9563, 0.0443, 203.39],
200: [0.9167, 0.0772, 205.04],
300: [0.8651, 0.1153, 207.08],
400: [0.7971, 0.1339, 211.53],
500: [0.7148, 0.1257, 215.22],
600: [0.6089, 0.1109, 221.72],
700: [0.5198, 0.0936, 223.13],
800: [0.45, 0.0771, 224.28],
900: [0.3982, 0.0664, 227.39],
950: [0.3018, 0.0541, 229.7],
},
},
{
name: "Sky",
shades: {
50: [0.9771, 0.0125, 236.62],
100: [0.9514, 0.0252, 236.82],
200: [0.9014, 0.0555, 230.9],
300: [0.8276, 0.1013, 230.32],
400: [0.7535, 0.139, 232.66],
500: [0.6847, 0.1479, 237.32],
600: [0.5876, 0.1389, 241.97],
700: [0.5, 0.1193, 242.75],
800: [0.4434, 0.124, 240.79],
900: [0.3912, 0.0845, 240.88],
950: [0.2935, 0.0632, 243.16],
},
},
{
name: "Blue",
shades: {
50: [0.9705, 0.0142, 254.6],
100: [0.9319, 0.0316, 255.59],
200: [0.8823, 0.0571, 254.13],
300: [0.8091, 0.0956, 251.81],
400: [0.7137, 0.1434, 254.62],
500: [0.6231, 0.188, 259.81],
600: [0.5461, 0.2152, 262.88],
700: [0.4882, 0.2172, 264.38],
800: [0.4244, 0.1809, 265.64],
900: [0.3791, 0.1378, 265.52],
950: [0.2823, 0.0874, 267.94],
},
},
{
name: "Indigo",
shades: {
50: [0.9619, 0.0179, 272.31],
100: [0.9299, 0.0334, 272.79],
200: [0.8699, 0.0622, 274.04],
300: [0.7853, 0.1041, 274.71],
400: [0.6801, 0.1583, 276.93],
500: [0.5854, 0.2041, 277.12],
600: [0.5106, 0.2301, 276.97],
700: [0.4568, 0.2146, 277.02],
800: [0.3984, 0.1773, 277.37],
900: [0.3588, 0.1354, 278.7],
950: [0.2573, 0.0861, 281.29],
},
},
{
name: "Violet",
shades: {
50: [0.9786, 0.0142, 308.3],
100: [0.9433, 0.0284, 294.59],
200: [0.8943, 0.0549, 293.28],
300: [0.8112, 0.1013, 293.57],
400: [0.709, 0.1592, 293.54],
500: [0.6056, 0.2189, 292.72],
600: [0.5413, 0.2466, 293.01],
700: [0.4907, 0.2412, 292.58],
800: [0.432, 0.2106, 292.76],
900: [0.3796, 0.1783, 293.74],
950: [0.2827, 0.1351, 291.09],
},
},
{
name: "Purple",
shades: {
50: [0.9768, 0.0142, 308.3],
100: [0.9464, 0.0327, 307.17],
200: [0.9024, 0.0604, 306.7],
300: [0.8268, 0.1082, 306.38],
400: [0.7217, 0.1767, 305.5],
500: [0.6268, 0.2325, 303.9],
600: [0.5575, 0.2525, 302.32],
700: [0.4955, 0.2369, 301.92],
800: [0.4383, 0.1983, 303.72],
900: [0.3807, 0.1661, 304.99],
950: [0.2905, 0.1432, 302.72],
},
},
{
name: "Fuchsia",
shades: {
50: [0.9773, 0.0174, 320.06],
100: [0.952, 0.036, 318.85],
200: [0.903, 0.0732, 319.62],
300: [0.833, 0.1322, 321.43],
400: [0.7477, 0.207, 322.16],
500: [0.6668, 0.2591, 322.15],
600: [0.5915, 0.2569, 322.9],
700: [0.518, 0.2258, 323.95],
800: [0.4519, 0.1922, 324.59],
900: [0.4007, 0.1601, 325.61],
950: [0.2932, 0.1309, 325.66],
},
},
{
name: "Pink",
shades: {
50: [0.9714, 0.0141, 343.2],
100: [0.9482, 0.0276, 342.26],
200: [0.8994, 0.0589, 343.23],
300: [0.8228, 0.1095, 346.02],
400: [0.7253, 0.1752, 349.76],
500: [0.6559, 0.2118, 354.31],
600: [0.5916, 0.218, 0.58],
700: [0.5246, 0.199, 3.96],
800: [0.4587, 0.1697, 3.82],
900: [0.4078, 0.1442, 2.43],
950: [0.2845, 0.1048, 3.91],
},
},
{
name: "Rose",
shades: {
50: [0.9694, 0.0151, 12.42],
100: [0.9414, 0.0297, 12.58],
200: [0.8924, 0.0559, 10],
300: [0.8097, 0.1061, 11.64],
400: [0.7192, 0.169, 13.43],
500: [0.645, 0.2154, 16.44],
600: [0.5858, 0.222, 17.58],
700: [0.5143, 0.1978, 16.93],
800: [0.4546, 0.1713, 13.7],
900: [0.4103, 0.1502, 10.27],
950: [0.2708, 0.1009, 12.09],
},
},
];
const SHADE_KEYS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
// =============================================================================
// Canvas Drawing
// =============================================================================
function drawColorArea(canvas: HTMLCanvasElement, hue: number) {
const ctx = canvas.getContext("2d", { willReadFrequently: false });
if (!ctx) return;
const { width, height } = canvas;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
const lightness = 1 - y / (height - 1);
for (let x = 0; x < width; x++) {
const chroma = (x / (width - 1)) * 0.4;
const [r, g, b] = gamutMapOklch(lightness, chroma, hue);
const idx = (y * width + x) * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
}
function drawHueStrip(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { width, height } = canvas;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let x = 0; x < width; x++) {
const hue = (x / (width - 1)) * 360;
const [r, g, b] = oklchToRgb(0.7, 0.15, hue);
for (let y = 0; y < height; y++) {
const idx = (y * width + x) * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
}
function drawAlphaStrip(canvas: HTMLCanvasElement, l: number, c: number, h: number) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { width, height } = canvas;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const [cr, cg, cb] = gamutMapOklch(l, c, h);
for (let x = 0; x < width; x++) {
const alpha = x / (width - 1);
for (let y = 0; y < height; y++) {
const idx = (y * width + x) * 4;
const isLight = (Math.floor(x / 5) + Math.floor(y / 5)) % 2 === 0;
const bg = isLight ? 255 : 200;
data[idx] = Math.round(cr * alpha + bg * (1 - alpha));
data[idx + 1] = Math.round(cg * alpha + bg * (1 - alpha));
data[idx + 2] = Math.round(cb * alpha + bg * (1 - alpha));
data[idx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
}
// =============================================================================
// Hook: Drag
// =============================================================================
function useDrag(onDrag: (x: number, y: number, rect: DOMRect) => void) {
const ref = React.useRef<HTMLDivElement>(null);
const dragging = React.useRef(false);
const handlePointerDown = React.useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
dragging.current = true;
const el = ref.current;
if (!el) return;
el.setPointerCapture(e.pointerId);
onDrag(e.clientX, e.clientY, el.getBoundingClientRect());
},
[onDrag]
);
const handlePointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!dragging.current) return;
const el = ref.current;
if (!el) return;
onDrag(e.clientX, e.clientY, el.getBoundingClientRect());
},
[onDrag]
);
const handlePointerUp = React.useCallback(() => {
dragging.current = false;
}, []);
return { ref, handlePointerDown, handlePointerMove, handlePointerUp };
}
// =============================================================================
// Sub-components
// =============================================================================
/** Fixed content height for consistent layout across tabs */
const CONTENT_H = "h-48";
function ColorArea({ color, onChange }: { color: OklchColor; onChange: (c: OklchColor) => void }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const c = canvasRef.current;
if (!c) return;
c.width = 220;
c.height = 140;
drawColorArea(c, color.h);
}, [color.h]);
const handleDrag = React.useCallback(
(cx: number, cy: number, rect: DOMRect) => {
const x = clamp((cx - rect.left) / rect.width, 0, 1);
const y = clamp((cy - rect.top) / rect.height, 0, 1);
onChange({ ...color, l: round4(1 - y), c: round4(x * 0.4) });
},
[color, onChange]
);
const drag = useDrag(handleDrag);
const thumbX = (color.c / 0.4) * 100,
thumbY = (1 - color.l) * 100;
const [tr, tg, tb] = gamutMapOklch(color.l, color.c, color.h);
return (
<div
ref={drag.ref}
className="relative w-full cursor-crosshair rounded-sm flex-1"
onPointerDown={drag.handlePointerDown}
onPointerMove={drag.handlePointerMove}
onPointerUp={drag.handlePointerUp}
role="slider"
aria-label="Color area: drag to adjust lightness and chroma"
tabIndex={0}
onKeyDown={(e) => {
const s = e.shiftKey ? 0.05 : 0.01;
if (e.key === "ArrowRight") onChange({ ...color, c: clamp(color.c + s * 0.4, 0, 0.4) });
else if (e.key === "ArrowLeft") onChange({ ...color, c: clamp(color.c - s * 0.4, 0, 0.4) });
else if (e.key === "ArrowUp") onChange({ ...color, l: clamp(color.l + s, 0, 1) });
else if (e.key === "ArrowDown") onChange({ ...color, l: clamp(color.l - s, 0, 1) });
}}
>
<canvas
ref={canvasRef}
className="block h-full w-full rounded-sm"
style={{ imageRendering: "pixelated" }}
/>
<div
className="pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-xs"
style={{
left: `${thumbX}%`,
top: `${thumbY}%`,
backgroundColor: `rgb(${tr}, ${tg}, ${tb})`,
}}
/>
</div>
);
}
function HueSlider({ hue, onChange }: { hue: number; onChange: (h: number) => void }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const c = canvasRef.current;
if (!c) return;
c.width = 220;
c.height = 12;
drawHueStrip(c);
}, []);
const handleDrag = React.useCallback(
(cx: number, _: number, rect: DOMRect) => {
onChange(round2(clamp((cx - rect.left) / rect.width, 0, 1) * 360));
},
[onChange]
);
const drag = useDrag(handleDrag);
const [r, g, b] = oklchToRgb(0.7, 0.15, hue);
return (
<div
ref={drag.ref}
className="relative h-2 w-full cursor-pointer rounded-full"
onPointerDown={drag.handlePointerDown}
onPointerMove={drag.handlePointerMove}
onPointerUp={drag.handlePointerUp}
role="slider"
aria-label="Hue"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hue)}
tabIndex={0}
onKeyDown={(e) => {
const s = e.shiftKey ? 10 : 1;
if (e.key === "ArrowRight") onChange((hue + s) % 360);
else if (e.key === "ArrowLeft") onChange((hue - s + 360) % 360);
}}
>
<canvas ref={canvasRef} className="block h-full w-full rounded-full" />
<div
className="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-sm"
style={{ left: `${(hue / 360) * 100}%`, backgroundColor: `rgb(${r}, ${g}, ${b})` }}
/>
</div>
);
}
function AlphaSlider({ color, onChange }: { color: OklchColor; onChange: (a: number) => void }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const c = canvasRef.current;
if (!c) return;
c.width = 220;
c.height = 12;
drawAlphaStrip(c, color.l, color.c, color.h);
}, [color.l, color.c, color.h]);
const handleDrag = React.useCallback(
(cx: number, _: number, rect: DOMRect) => {
onChange(round2(clamp((cx - rect.left) / rect.width, 0, 1)));
},
[onChange]
);
const drag = useDrag(handleDrag);
const [r, g, b] = gamutMapOklch(color.l, color.c, color.h);
return (
<div
ref={drag.ref}
className="relative h-2 w-full cursor-pointer rounded-full"
onPointerDown={drag.handlePointerDown}
onPointerMove={drag.handlePointerMove}
onPointerUp={drag.handlePointerUp}
role="slider"
aria-label="Opacity"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(color.a * 100)}
tabIndex={0}
onKeyDown={(e) => {
const s = e.shiftKey ? 0.1 : 0.01;
if (e.key === "ArrowRight") onChange(clamp(color.a + s, 0, 1));
else if (e.key === "ArrowLeft") onChange(clamp(color.a - s, 0, 1));
}}
>
<canvas ref={canvasRef} className="block h-full w-full rounded-full" />
<div
className="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-sm"
style={{ left: `${color.a * 100}%`, backgroundColor: `rgba(${r}, ${g}, ${b}, ${color.a})` }}
/>
</div>
);
}
// --- Numeric Input -----------------------------------------------------------
function NumField({
label,
value,
min,
max,
step,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
}) {
const [text, setText] = React.useState(String(value));
React.useEffect(() => {
setText(String(value));
}, [value]);
const commit = () => {
const n = parseFloat(text);
if (!isNaN(n)) onChange(clamp(n, min, max));
else setText(String(value));
};
return (
<div className="flex flex-1 flex-col gap-0.5">
<label className="text-[10px] font-medium uppercase text-muted-foreground text-center">
{label}
</label>
<Input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "ArrowUp") {
e.preventDefault();
onChange(clamp(value + (step ?? 1), min, max));
}
if (e.key === "ArrowDown") {
e.preventDefault();
onChange(clamp(value - (step ?? 1), min, max));
}
}}
className="h-7 rounded-lg px-1 text-center text-xs tabular-nums shadow-none"
aria-label={label}
/>
</div>
);
}
// --- Values Tab (full input panel) -------------------------------------------
type InputMode = "oklch" | "hsl" | "rgb" | "hex";
function ValuesPanel({
color,
onChange,
showAlpha,
}: {
color: OklchColor;
onChange: (c: OklchColor) => void;
showAlpha: boolean;
}) {
const [mode, setMode] = React.useState<InputMode>("oklch");
const modes: InputMode[] = ["oklch", "hsl", "rgb", "hex"];
const [r, g, b] = gamutMapOklch(color.l, color.c, color.h);
const bgColor = color.a < 1 ? `rgba(${r}, ${g}, ${b}, ${color.a})` : `rgb(${r}, ${g}, ${b})`;
return (
<div className={cn(CONTENT_H, "flex flex-col gap-3 justify-start")}>
{/* Preview swatch */}
<div className="flex items-center gap-3">
<div className="size-10 shrink-0 rounded-sm" style={{ backgroundColor: bgColor }} />
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-[10px] font-medium text-muted-foreground uppercase">
Current Color
</span>
<span className="text-xs font-mono text-foreground truncate">
{formatColor(color, mode)}
</span>
</div>
</div>
{/* Color space tabs */}
<div className="flex gap-0.5 rounded-lg bg-muted p-0.5">
{modes.map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={cn(
"flex-1 rounded-sm px-1.5 py-0.5 text-[10px] font-medium uppercase transition-colors",
mode === m
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{m}
</button>
))}
</div>
{/* Inputs */}
<div className="flex gap-1">
{mode === "oklch" && (
<>
<NumField
label="L"
value={round4(color.l)}
min={0}
max={1}
step={0.01}
onChange={(l) => onChange({ ...color, l })}
/>
<NumField
label="C"
value={round4(color.c)}
min={0}
max={0.4}
step={0.005}
onChange={(c) => onChange({ ...color, c })}
/>
<NumField
label="H"
value={round2(color.h)}
min={0}
max={360}
step={1}
onChange={(h) => onChange({ ...color, h })}
/>
{showAlpha && (
<NumField
label="A"
value={round2(color.a)}
min={0}
max={1}
step={0.01}
onChange={(a) => onChange({ ...color, a })}
/>
)}
</>
)}
{mode === "hsl" &&
(() => {
const [hh, ss, ll] = rgbToHsl(r, g, b);
const update = (h: number, s: number, l: number) => {
const [nr, ng, nb] = hslToRgb(h, s, l);
onChange({ ...rgbToOklch(nr, ng, nb), a: color.a });
};
return (
<>
<NumField
label="H"
value={hh}
min={0}
max={360}
step={1}
onChange={(v) => update(v, ss, ll)}
/>
<NumField
label="S"
value={ss}
min={0}
max={100}
step={1}
onChange={(v) => update(hh, v, ll)}
/>
<NumField
label="L"
value={ll}
min={0}
max={100}
step={1}
onChange={(v) => update(hh, ss, v)}
/>
{showAlpha && (
<NumField
label="A"
value={round2(color.a)}
min={0}
max={1}
step={0.01}
onChange={(a) => onChange({ ...color, a })}
/>
)}
</>
);
})()}
{mode === "rgb" &&
(() => {
const update = (nr: number, ng: number, nb: number) =>
onChange({ ...rgbToOklch(nr, ng, nb), a: color.a });
return (
<>
<NumField
label="R"
value={r}
min={0}
max={255}
step={1}
onChange={(v) => update(v, g, b)}
/>
<NumField
label="G"
value={g}
min={0}
max={255}
step={1}
onChange={(v) => update(r, v, b)}
/>
<NumField
label="B"
value={b}
min={0}
max={255}
step={1}
onChange={(v) => update(r, g, v)}
/>
{showAlpha && (
<NumField
label="A"
value={round2(color.a)}
min={0}
max={1}
step={0.01}
onChange={(a) => onChange({ ...color, a })}
/>
)}
</>
);
})()}
{mode === "hex" && <HexInput color={color} onChange={onChange} showAlpha={showAlpha} />}
</div>
</div>
);
}
function HexInput({
color,
onChange,
showAlpha,
}: {
color: OklchColor;
onChange: (c: OklchColor) => void;
showAlpha: boolean;
}) {
const [r, g, b] = gamutMapOklch(color.l, color.c, color.h);
const hex = rgbToHex(r, g, b, showAlpha && color.a < 1 ? color.a : undefined);
const [text, setText] = React.useState(hex);
React.useEffect(() => {
setText(hex);
}, [hex]);
const commit = () => {
const cleaned = text.startsWith("#") ? text : `#${text}`;
if (/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(cleaned)) {
const parsed = hexToRgb(cleaned);
onChange({ ...rgbToOklch(parsed.r, parsed.g, parsed.b), a: parsed.a });
} else setText(hex);
};
return (
<div className="flex flex-1 flex-col gap-0.5">
<label className="text-[10px] font-medium uppercase text-muted-foreground text-center">
Hex
</label>
<Input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
className="h-7 rounded-lg px-1.5 text-center text-xs font-mono tabular-nums shadow-none"
aria-label="Hex color value"
/>
</div>
);
}
// --- Tailwind Panel (vertical columns, horizontal scroll) --------------------
function TailwindPanel({
color,
onSelect,
}: {
color: OklchColor;
onSelect: (c: OklchColor) => void;
}) {
return (
<div className={cn(CONTENT_H, "overflow-x-auto overflow-y-visible rounded-sm")}>
<div className="flex gap-px h-full">
{TAILWIND_PALETTE.map((family) => (
<div key={family.name} className="flex flex-col items-center gap-px shrink-0">
{SHADE_KEYS.map((shade) => {
const [l, c, h] = family.shades[shade];
const [r, g, b] = gamutMapOklch(l, c, h);
const isActive =
Math.abs(color.l - l) < 0.005 &&
Math.abs(color.c - c) < 0.005 &&
Math.abs(color.h - h) < 0.5;
return (
<button
key={shade}
type="button"
title={`${family.name} ${shade}`}
aria-label={`${family.name} ${shade}`}
className={cn(
"w-8 flex-1 rounded-xs transition-all hover:transition-none duration-300 hover:scale-105 focus-visible:border-2 focus-visible:border-foreground",
isActive
? "inset-ring-2 inset-ring-foreground/30"
: "hover:border-foreground/20"
)}
style={{ backgroundColor: `rgb(${r}, ${g}, ${b})` }}
onClick={() => onSelect({ l, c, h, a: 1 })}
/>
);
})}
</div>
))}
</div>
</div>
);
}
// --- Custom Presets ---
function CustomPresetsPanel({
presets,
onSelect,
}: {
presets: PresetColor[];
onSelect: (c: OklchColor) => void;
}) {
return (
<div className={cn(CONTENT_H, "flex flex-wrap content-start gap-1 overflow-y-auto p-0.5")}>
{presets.map((preset, i) => {
const parsed = parseColor(preset.value);
const [r, g, b] = gamutMapOklch(parsed.l, parsed.c, parsed.h);
return (
<button
key={i}
type="button"
title={preset.label}
aria-label={preset.label}
className="size-7 rounded-sm border border-transparent transition-all hover:scale-110 hover:border-foreground/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
style={{ backgroundColor: `rgb(${r}, ${g}, ${b})` }}
onClick={() => onSelect(parsed)}
/>
);
})}
</div>
);
}
// =============================================================================
// Main Component
// =============================================================================
type TopTab = "picker" | "tailwind" | "values";
function ColorPicker({
value,
defaultValue,
onValueChange,
format = "oklch",
showAlpha = false,
presets,
className,
}: ColorPickerProps) {
const [internalColor, setInternalColor] = React.useState<OklchColor>(() =>
parseColor(value ?? defaultValue ?? "oklch(0.7 0.15 230)")
);
React.useEffect(() => {
if (value !== undefined) setInternalColor(parseColor(value));
}, [value]);
const hasTailwind = presets === "tailwind";
const hasCustomPresets = Array.isArray(presets);
const showTabs = hasTailwind || hasCustomPresets;
const tabs: { id: TopTab; label: string }[] = [
{ id: "picker", label: "Picker" },
...(showTabs
? [{ id: "tailwind" as TopTab, label: hasTailwind ? "Tailwind" : "Presets" }]
: []),
{ id: "values", label: "Values" },
];
const [topTab, setTopTab] = React.useState<TopTab>("picker");
const color = value !== undefined ? parseColor(value) : internalColor;
const updateColor = React.useCallback(
(newColor: OklchColor) => {
if (value === undefined) setInternalColor(newColor);
onValueChange?.(formatColor(newColor, format));
},
[value, format, onValueChange]
);
return (
<div
data-slot="color-picker"
className={cn(
"flex w-[264px] flex-col gap-0 rounded-xl border bg-popover shadow-lg overflow-hidden",
className
)}
>
{/* Line-style tabs */}
<div className="flex border-b">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setTopTab(tab.id)}
className={cn(
"flex-1 py-1 text-[11px] font-medium transition-colors relative",
topTab === tab.id ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
{topTab === tab.id && (
<span className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-foreground" />
)}
</button>
))}
</div>
{/* Content area (fixed height) */}
<div className="p-2.5">
{topTab === "picker" && (
<div className={cn(CONTENT_H, "flex flex-col gap-2")}>
<ColorArea color={color} onChange={updateColor} />
<div className="flex items-center gap-1.5">
<div
className="size-7 shrink-0 rounded-sm"
style={{
backgroundColor: ((): string => {
const [r, g, b] = gamutMapOklch(color.l, color.c, color.h);
return color.a < 1 ? `rgba(${r},${g},${b},${color.a})` : `rgb(${r},${g},${b})`;
})(),
}}
/>
<div className="flex flex-1 flex-col gap-1.5 pr-2">
<HueSlider hue={color.h} onChange={(h) => updateColor({ ...color, h })} />
{showAlpha && (
<AlphaSlider color={color} onChange={(a) => updateColor({ ...color, a })} />
)}
</div>
</div>
</div>
)}
{topTab === "tailwind" && hasTailwind && (
<TailwindPanel color={color} onSelect={updateColor} />
)}
{topTab === "tailwind" && hasCustomPresets && (
<CustomPresetsPanel presets={presets} onSelect={updateColor} />
)}
{topTab === "values" && (
<ValuesPanel color={color} onChange={updateColor} showAlpha={showAlpha} />
)}
</div>
</div>
);
}
export { ColorPicker, parseColor, formatColor };
export type { ColorPickerProps, ColorFormat, OklchColor, PresetColor };
No selection tags yet — empty means no evidence, not lack of support.