"use client";
import * as React from "react";
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react";
import { Check, Copy, Pipette } from "lucide-react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------------------------
* Colour math
* ------------------------------------------------------------------------- */
export type HSVA = { h: number; s: number; v: number; a: number }; // h 0-360, s/v/a 0-1
export type RGBA = { r: number; g: number; b: number; a: number }; // 0-255, a 0-1
const clamp = (n: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, n));
const round = (n: number, d = 0) => Math.round(n * 10 ** d) / 10 ** d;
export function hsvToRgb({ h, s, v, a }: HSVA): RGBA {
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 };
}
export function rgbToHsv({ r, g, b, a }: RGBA): HSVA {
const R = r / 255;
const G = g / 255;
const B = b / 255;
const max = Math.max(R, G, B);
const d = max - Math.min(R, G, B);
let h = 0;
if (d) {
if (max === R) h = ((G - B) / d) % 6;
else if (max === G) h = (B - R) / d + 2;
else h = (R - G) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s: max ? d / max : 0, v: max, a };
}
const hex2 = (n: number) => Math.round(n).toString(16).padStart(2, "0");
export function rgbToHex({ r, g, b, a }: RGBA, withAlpha = true) {
return `#${hex2(r)}${hex2(g)}${hex2(b)}${withAlpha && a < 1 ? hex2(a * 255) : ""}`.toUpperCase();
}
function rgbToHsl({ r, g, b, a }: RGBA) {
const R = r / 255;
const G = g / 255;
const B = b / 255;
const max = Math.max(R, G, B);
const min = Math.min(R, G, B);
const l = (max + min) / 2;
const d = max - min;
const s = d ? d / (1 - Math.abs(2 * l - 1)) : 0;
const { h } = rgbToHsv({ r, g, b, a });
return { h, s, l, a };
}
function hslToRgb(h: number, s: number, l: number, a: number): RGBA {
const k = (n: number) => (n + h / 30) % 12;
const q = s * Math.min(l, 1 - l);
const f = (n: number) => l - q * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a };
}
/** Parses #rgb, #rgba, #rrggbb, #rrggbbaa, rgb()/rgba(), hsl()/hsla(). */
export function parseColor(input: string): RGBA | null {
const s = input.trim().toLowerCase();
const hex = s.match(/^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
if (hex) {
let h = hex[1];
if (h.length <= 4) h = h.split("").map((c) => c + c).join("");
const n = (i: number) => parseInt(h.slice(i, i + 2), 16);
return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? round(n(6) / 255, 2) : 1 };
}
const nums = (str: string) => str.split(/[\s,/]+/).filter(Boolean);
const pct = (x: string, max: number) => (x.endsWith("%") ? (parseFloat(x) / 100) * max : parseFloat(x));
const rgb = s.match(/^rgba?\((.+)\)$/);
if (rgb) {
const p = nums(rgb[1]);
if (p.length < 3) return null;
const [r, g, b] = p.slice(0, 3).map((x) => clamp(pct(x, 255), 0, 255));
const a = p[3] !== undefined ? clamp(pct(p[3], 1)) : 1;
return [r, g, b, a].some(Number.isNaN) ? null : { r: Math.round(r), g: Math.round(g), b: Math.round(b), a };
}
const hsl = s.match(/^hsla?\((.+)\)$/);
if (hsl) {
const p = nums(hsl[1]);
if (p.length < 3) return null;
const h = ((parseFloat(p[0]) % 360) + 360) % 360;
const sat = clamp(parseFloat(p[1]) / 100);
const l = clamp(parseFloat(p[2]) / 100);
const a = p[3] !== undefined ? clamp(pct(p[3], 1)) : 1;
return [h, sat, l, a].some(Number.isNaN) ? null : hslToRgb(h, sat, l, a);
}
return null;
}
function luminance({ r, g, b }: RGBA) {
const c = (x: number) => {
const v = x / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b);
}
const contrast = (a: RGBA, b: RGBA) => {
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
return (x + 0.05) / (y + 0.05);
};
export function formatColor(rgba: RGBA, format: "hex" | "rgb" | "hsl") {
if (format === "hex") return rgbToHex(rgba);
if (format === "rgb") return rgba.a < 1 ? `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${round(rgba.a, 2)})` : `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`;
const { h, s, l, a } = rgbToHsl(rgba);
const core = `${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%`;
return a < 1 ? `hsla(${core}, ${round(a, 2)})` : `hsl(${core})`;
}
/* ----------------------------------------------------------------------------
* Hooks
* ------------------------------------------------------------------------- */
type EyeDropperCtor = new () => { open: () => Promise<{ sRGBHex: string }> };
const noopSubscribe = () => () => {};
function useEyeDropper(): EyeDropperCtor | null {
const supported = React.useSyncExternalStore(
noopSubscribe,
() => "EyeDropper" in window,
() => false,
);
return supported ? (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper : null;
}
/** Pointer drag on an element; reports 0-1 coordinates. */
function useDrag(onMove: (x: number, y: number) => void) {
const ref = React.useRef<HTMLDivElement>(null);
const [dragging, setDragging] = React.useState(false);
const handlers = {
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
const el = ref.current;
if (!el) return;
el.setPointerCapture(e.pointerId);
el.focus({ preventScroll: true });
setDragging(true);
const r = el.getBoundingClientRect();
onMove(clamp((e.clientX - r.left) / r.width), clamp((e.clientY - r.top) / r.height));
},
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging || !ref.current) return;
const r = ref.current.getBoundingClientRect();
onMove(clamp((e.clientX - r.left) / r.width), clamp((e.clientY - r.top) / r.height));
},
onPointerUp: () => setDragging(false),
onPointerCancel: () => setDragging(false),
};
return { ref, dragging, handlers };
}
/* ----------------------------------------------------------------------------
* Component
* ------------------------------------------------------------------------- */
export type ColorValue = { hex: string; rgba: RGBA; css: string };
export interface ColorPickerProps {
defaultValue?: string;
onChange?: (color: ColorValue) => void;
swatches?: string[];
showAlpha?: boolean;
defaultFormat?: "hex" | "rgb" | "hsl";
label?: string;
className?: string;
}
const DEFAULT_SWATCHES = ["#0F172A", "#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6", "#0EA5E9", "#6366F1", "#A855F7", "#EC4899", "#F43F5E80", "#FFFFFF"];
const CHECKER =
"repeating-conic-gradient(color-mix(in oklab, currentColor 18%, transparent) 0 25%, transparent 0 50%) 0 0 / 10px 10px";
export function ColorPicker({
defaultValue = "#6366F1",
onChange,
swatches = DEFAULT_SWATCHES,
showAlpha = true,
defaultFormat = "hex",
label = "Colour",
className,
}: ColorPickerProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const EyeDropper = useEyeDropper();
const [hsva, setHsva] = React.useState<HSVA>(() => rgbToHsv(parseColor(defaultValue) ?? { r: 99, g: 102, b: 241, a: 1 }));
const [initial] = React.useState(hsva);
const [format, setFormat] = React.useState(defaultFormat);
const [draft, setDraft] = React.useState<string | null>(null);
const shake = useAnimationControls();
const [copied, setCopied] = React.useState(false);
const rgba = hsvToRgb(hsva);
const hex = rgbToHex(rgba);
const css = formatColor(rgba, format);
const opaque = rgbToHex(rgba, false);
const hueColor = `hsl(${hsva.h} 100% 50%)`;
const set = (next: HSVA) => {
setHsva(next);
setDraft(null);
const c = hsvToRgb(next);
onChange?.({ hex: rgbToHex(c), rgba: c, css: formatColor(c, format) });
};
const sv = useDrag((x, y) => set({ ...hsva, s: x, v: 1 - y }));
const hue = useDrag((x) => set({ ...hsva, h: x * 360 }));
const alpha = useDrag((x) => set({ ...hsva, a: round(x, 2) }));
const keyStep = (e: React.KeyboardEvent) => (e.shiftKey ? 10 : 1);
const onSvKey = (e: React.KeyboardEvent) => {
const d = keyStep(e) / 100;
const m: Record<string, Partial<HSVA>> = {
ArrowLeft: { s: clamp(hsva.s - d) },
ArrowRight: { s: clamp(hsva.s + d) },
ArrowUp: { v: clamp(hsva.v + d) },
ArrowDown: { v: clamp(hsva.v - d) },
Home: { s: 0 },
End: { s: 1 },
PageUp: { v: 1 },
PageDown: { v: 0 },
};
if (!m[e.key]) return;
e.preventDefault();
set({ ...hsva, ...m[e.key] });
};
const sliderKey = (value: number, max: number, step: number, apply: (n: number) => void) => (e: React.KeyboardEvent) => {
const d = step * (e.shiftKey ? 10 : 1);
const m: Record<string, number> = {
ArrowLeft: value - d,
ArrowDown: value - d,
ArrowRight: value + d,
ArrowUp: value + d,
Home: 0,
End: max,
PageUp: value + step * 10,
PageDown: value - step * 10,
};
if (m[e.key] === undefined) return;
e.preventDefault();
apply(Math.min(max, Math.max(0, m[e.key])));
};
const commitText = (revertInvalid = false) => {
if (draft === null) return;
const parsed = parseColor(draft);
if (!parsed) {
if (revertInvalid) setDraft(null);
else if (!reduce) void shake.start({ x: [0, -6, 6, -4, 4, 0], transition: { duration: 0.35 } });
return;
}
const next = rgbToHsv(parsed);
// keep hue when the colour is grey so the square doesn't jump
set(next.s === 0 || next.v === 0 ? { ...next, h: hsva.h } : next);
};
const pickFromScreen = async () => {
if (!EyeDropper) return;
try {
const res = await new EyeDropper().open();
const p = parseColor(res.sRGBHex);
if (p) set({ ...rgbToHsv(p), a: hsva.a });
} catch {
/* cancelled */
}
};
const copy = async () => {
try {
await navigator.clipboard.writeText(css);
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
} catch {
/* clipboard blocked */
}
};
const white = { r: 255, g: 255, b: 255, a: 1 };
const black = { r: 0, g: 0, b: 0, a: 1 };
const cw = contrast(rgba, white);
const cb = contrast(rgba, black);
const best = cw >= cb ? { ratio: cw, on: "white" } : { ratio: cb, on: "black" };
const grade = best.ratio >= 7 ? "AAA" : best.ratio >= 4.5 ? "AA" : best.ratio >= 3 ? "AA Large" : "Fail";
const initialRgb = hsvToRgb(initial);
const thumb = (dragging: boolean) =>
cn(
"pointer-events-none absolute size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white shadow-[0_0_0_1px_rgba(0,0,0,.25),0_2px_6px_rgba(0,0,0,.35)] transition-transform duration-150",
dragging && "scale-125",
);
const track =
"relative h-3.5 cursor-pointer touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card";
return (
<div
role="group"
aria-labelledby={`${uid}-label`}
className={cn("w-full max-w-[600px] rounded-2xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5", className)}
>
<div className="mb-4 flex items-center justify-between">
<h3 id={`${uid}-label`} className="text-sm font-semibold">
{label}
</h3>
<span className="font-mono text-xs text-muted-foreground">{hex}</span>
</div>
<div className="grid gap-5 sm:grid-cols-[minmax(0,1fr)_220px]">
{/* left: square + sliders */}
<div className="flex flex-col gap-3.5">
<div
ref={sv.ref}
{...sv.handlers}
role="slider"
tabIndex={0}
aria-label="Saturation and brightness"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(hsva.s * 100)}
aria-valuetext={`Saturation ${Math.round(hsva.s * 100)}%, brightness ${Math.round(hsva.v * 100)}%`}
onKeyDown={onSvKey}
className="relative aspect-[4/3] w-full cursor-crosshair touch-none rounded-xl outline-none ring-offset-2 ring-offset-card focus-visible:ring-2 focus-visible:ring-ring sm:aspect-auto sm:h-52"
style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), ${hueColor}` }}
>
<span className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-black/10" />
<span className={thumb(sv.dragging)} style={{ left: `${hsva.s * 100}%`, top: `${(1 - hsva.v) * 100}%`, background: opaque }} />
</div>
<div
ref={hue.ref}
{...hue.handlers}
role="slider"
tabIndex={0}
aria-label="Hue"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hsva.h)}
aria-valuetext={`${Math.round(hsva.h)} degrees`}
onKeyDown={sliderKey(hsva.h, 360, 1, (h) => set({ ...hsva, h }))}
className={track}
style={{ background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)" }}
>
<span className={cn(thumb(hue.dragging), "top-1/2")} style={{ left: `${(hsva.h / 360) * 100}%`, background: hueColor }} />
</div>
{showAlpha && (
<div
ref={alpha.ref}
{...alpha.handlers}
role="slider"
tabIndex={0}
aria-label="Opacity"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(hsva.a * 100)}
aria-valuetext={`${Math.round(hsva.a * 100)}%`}
onKeyDown={sliderKey(Math.round(hsva.a * 100), 100, 1, (a) => set({ ...hsva, a: a / 100 }))}
className={cn(track, "text-foreground")}
style={{ background: CHECKER }}
>
<span className="pointer-events-none absolute inset-0 rounded-full" style={{ background: `linear-gradient(to right, transparent, ${opaque})` }} />
<span className={cn(thumb(alpha.dragging), "top-1/2")} style={{ left: `${hsva.a * 100}%`, background: css }} />
</div>
)}
</div>
{/* right: preview, inputs, swatches */}
<div className="flex min-w-0 flex-col gap-3.5">
<div className="flex items-center gap-3">
<div className="relative h-12 flex-1 overflow-hidden rounded-xl text-foreground" style={{ background: CHECKER }} aria-hidden>
<div className="absolute inset-y-0 left-0 w-1/3" style={{ background: formatColor(initialRgb, "rgb") }} title="Original" />
<motion.div
className="absolute inset-y-0 left-1/3 right-0 grid place-items-center text-[11px] font-semibold"
animate={{ backgroundColor: formatColor(rgba, "rgb") }}
transition={{ duration: reduce ? 0 : 0.15 }}
style={{ color: best.on }}
>
Aa
</motion.div>
<span className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-black/10 dark:ring-white/10" />
</div>
{EyeDropper && (
<button
type="button"
onClick={pickFromScreen}
aria-label="Pick a colour from the screen"
className="grid size-12 shrink-0 place-items-center rounded-xl border bg-background text-muted-foreground outline-none transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
<Pipette className="size-4" />
</button>
)}
</div>
<div>
<div role="radiogroup" aria-label="Format" className="mb-2 flex rounded-lg bg-muted p-0.5 text-xs">
{(["hex", "rgb", "hsl"] as const).map((f) => (
<button
key={f}
type="button"
role="radio"
aria-checked={format === f}
onClick={() => {
setFormat(f);
setDraft(null);
}}
className="relative flex-1 rounded-md py-1 font-medium uppercase text-muted-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40 aria-checked:text-foreground"
>
{format === f && (
<motion.span layoutId={`${uid}-fmt`} className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 36 }} />
)}
<span className="relative">{f}</span>
</button>
))}
</div>
<motion.div animate={shake} className="relative">
<label htmlFor={`${uid}-text`} className="sr-only">
{format.toUpperCase()} value
</label>
<input
id={`${uid}-text`}
value={draft ?? css}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => commitText(true)}
onKeyDown={(e) => {
if (e.key === "Enter") commitText();
if (e.key === "Escape") setDraft(null);
}}
spellCheck={false}
aria-invalid={draft !== null && !parseColor(draft)}
className="h-9 w-full rounded-lg border bg-background pl-2.5 pr-9 font-mono text-[13px] outline-none transition focus:border-ring focus:ring-4 focus:ring-ring/15 aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/15"
/>
<button
type="button"
onClick={copy}
aria-label={copied ? "Copied" : "Copy value"}
className="absolute right-1 top-1 grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
<AnimatePresence mode="wait" initial={false}>
<motion.span key={copied ? "y" : "n"} initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }} transition={{ duration: 0.12 }}>
{copied ? <Check className="size-3.5 text-emerald-500" /> : <Copy className="size-3.5" />}
</motion.span>
</AnimatePresence>
</button>
</motion.div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">Swatches</p>
<div className="grid grid-cols-6 gap-2">
{swatches.map((sw) => {
const p = parseColor(sw);
if (!p) return null;
const on = rgbToHex(p) === hex;
return (
<motion.button
key={sw}
type="button"
whileHover={reduce ? undefined : { scale: 1.12 }}
whileTap={reduce ? undefined : { scale: 0.92 }}
aria-label={`Use ${rgbToHex(p)}`}
aria-pressed={on}
onClick={() => set(rgbToHsv(p))}
className="relative aspect-square rounded-lg text-foreground outline-none ring-offset-2 ring-offset-card focus-visible:ring-2 focus-visible:ring-ring"
style={{ background: CHECKER }}
>
<span className="absolute inset-0 rounded-lg ring-1 ring-inset ring-black/10 dark:ring-white/10" style={{ background: formatColor(p, "rgb") }} />
{on && (
<motion.span layoutId={`${uid}-sw`} className="absolute -inset-1 rounded-[10px] border-2 border-foreground" transition={{ type: "spring", stiffness: 500, damping: 34 }} />
)}
</motion.button>
);
})}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/40 px-2.5 py-2 text-xs">
<span className="text-muted-foreground">
Contrast on {best.on} <span className="font-medium tabular-nums text-foreground">{best.ratio.toFixed(2)}:1</span>
</span>
<span
className={cn(
"rounded-md px-1.5 py-0.5 font-semibold",
grade === "Fail" ? "bg-destructive/15 text-destructive" : "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
)}
>
{grade}
</span>
</div>
</div>
</div>
</div>
);
}