"use client";
import * as React from "react";
/* -------------------------------------------------------------------------- */
/* Colour math (sRGB, no dependencies) */
/* -------------------------------------------------------------------------- */
/** Hue 0–360, saturation / value / alpha 0–1. HSV keeps the hue when a colour turns grey. */
export interface Hsva {
h: number;
s: number;
v: number;
a: number;
}
type Rgb = [number, number, number];
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
function hsvToRgb({ h, s, v }: Hsva): Rgb {
const f = (n: number) => {
const k = (n + h / 60) % 6;
return (v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255;
};
return [f(5), f(3), f(1)];
}
function hueOf(r: number, g: number, b: number, max: number, d: number) {
if (d === 0) return null;
let h: number;
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
return (h * 60 + 360) % 360;
}
/** RGB (0–255) to HSV; hue / saturation are taken from `prev` when they are undefined (greys, black). */
function rgbToHsva([r, g, b]: Rgb, a: number, prev?: Hsva): Hsva {
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
const max = Math.max(rn, gn, bn);
const d = max - Math.min(rn, gn, bn);
const h = hueOf(rn, gn, bn, max, d);
return {
h: h ?? prev?.h ?? 0,
s: max === 0 ? (prev?.s ?? 0) : d / max,
v: max,
a: clamp(a, 0, 1),
};
}
function rgbToHsl([r, g, b]: Rgb, fallbackHue: number): [number, number, number] {
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const d = max - min;
const l = (max + min) / 2;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
return [hueOf(rn, gn, bn, max, d) ?? fallbackHue, s, l];
}
function hslToRgb(h: number, s: number, l: number): Rgb {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
return (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255;
};
return [f(0), f(8), f(4)];
}
const hex2 = (n: number) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, "0").toUpperCase();
/** "#RRGGBB", or "#RRGGBBAA" when the colour is not fully opaque. */
export function toHex(c: Hsva) {
const [r, g, b] = hsvToRgb(c);
return `#${hex2(r)}${hex2(g)}${hex2(b)}${c.a < 1 ? hex2(c.a * 255) : ""}`;
}
/** Parses one channel: plain numbers are 0–`scale`, percentages map to 0–`scale`. */
function channel(token: string, scale: number) {
const pct = token.endsWith("%");
const n = parseFloat(token);
if (!Number.isFinite(n)) return NaN;
return pct ? (n / 100) * scale : n;
}
/**
* Parses hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba() and hsl()/hsla() — comma or
* space separated — into HSVA. Returns null for anything else.
*/
export function parseColor(input: string, prev?: Hsva): Hsva | null {
const text = input.trim().toLowerCase();
const hex = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text);
if (hex) {
let digits = hex[1];
if (digits.length <= 4) digits = [...digits].map((d) => d + d).join("");
const n = (i: number) => parseInt(digits.slice(i, i + 2), 16);
return rgbToHsva([n(0), n(2), n(4)], digits.length === 8 ? n(6) / 255 : 1, prev);
}
const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text);
if (!fn) return null;
const parts = fn[2].split(/[\s,/]+/).filter(Boolean);
if (parts.length !== 3 && parts.length !== 4) return null;
const alpha = parts[3] === undefined ? 1 : channel(parts[3], 1);
if (fn[1].startsWith("rgb")) {
const rgb = parts.slice(0, 3).map((p) => channel(p, 255));
if ([...rgb, alpha].some((n) => !Number.isFinite(n) || n < 0) || rgb.some((n) => n > 255) || alpha > 1) return null;
return rgbToHsva(rgb as Rgb, alpha, prev);
}
const h = parseFloat(parts[0]);
const s = channel(parts[1].endsWith("%") ? parts[1] : `${parts[1]}%`, 1);
const l = channel(parts[2].endsWith("%") ? parts[2] : `${parts[2]}%`, 1);
if (![h, s, l, alpha].every(Number.isFinite) || s < 0 || s > 1 || l < 0 || l > 1 || alpha < 0 || alpha > 1) return null;
const hue = ((h % 360) + 360) % 360;
const parsed = rgbToHsva(hslToRgb(hue, s, l), alpha, prev);
// Keep the typed hue even for greys.
return s === 0 || l === 0 || l === 1 ? { ...parsed, h: hue } : parsed;
}
export type ColorFormat = "hex" | "rgb" | "hsl";
const round = (n: number, digits = 0) => Number(n.toFixed(digits));
function formatColor(c: Hsva, format: ColorFormat, withAlpha: boolean) {
const a = withAlpha ? round(c.a, 2) : 1;
if (format === "hex") return toHex({ ...c, a });
const rgb = hsvToRgb(c);
if (format === "rgb") {
const [r, g, b] = rgb.map((n) => Math.round(n));
return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
}
const [h, s, l] = rgbToHsl(rgb, c.h);
const body = `${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%`;
return a < 1 ? `hsla(${body}, ${a})` : `hsl(${body})`;
}
const HUE_NAMES: [number, string][] = [
[10, "red"],
[40, "orange"],
[65, "yellow"],
[90, "lime"],
[155, "green"],
[185, "teal"],
[205, "cyan"],
[245, "blue"],
[280, "violet"],
[320, "magenta"],
[345, "pink"],
[361, "red"],
];
const hueName = (h: number) => HUE_NAMES.find(([limit]) => h < limit)?.[1] ?? "red";
/* -------------------------------------------------------------------------- */
/* Browser features */
/* -------------------------------------------------------------------------- */
interface EyeDropperInstance {
open: () => Promise<{ sRGBHex: string }>;
}
type EyeDropperConstructor = new () => EyeDropperInstance;
const noopSubscribe = () => () => {};
/** False on the server and during hydration, so markup stays deterministic. */
function useEyeDropperSupport() {
return React.useSyncExternalStore(noopSubscribe, () => "EyeDropper" in window, () => false);
}
/**
* Collapses pointer moves into one update per animation frame, so dragging never sets state
* more often than the screen refreshes.
*/
function useFrameThrottle<T>(apply: (value: T) => void) {
const frame = React.useRef<number | null>(null);
const pending = React.useRef<T | null>(null);
const applyRef = React.useRef(apply);
React.useEffect(() => {
applyRef.current = apply;
});
React.useEffect(
() => () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
},
[],
);
return (value: T) => {
pending.current = value;
if (frame.current !== null) return;
frame.current = requestAnimationFrame(() => {
frame.current = null;
if (pending.current !== null) applyRef.current(pending.current);
pending.current = null;
});
};
}
/* -------------------------------------------------------------------------- */
/* Channel slider (hue / alpha) */
/* -------------------------------------------------------------------------- */
interface ChannelSliderProps {
label: string;
value: number;
max: number;
valueText: string;
background: string;
thumbColor: string;
checker?: boolean;
disabled: boolean;
onChange: (value: number) => void;
onCommit: () => void;
}
function ChannelSlider({ label, value, max, valueText, background, thumbColor, checker, disabled, onChange, onCommit }: ChannelSliderProps) {
const trackRef = React.useRef<HTMLDivElement>(null);
const dragging = React.useRef(false);
const fromPointer = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
onChange(clamp((clientX - rect.left) / rect.width, 0, 1) * max);
};
const throttled = useFrameThrottle(fromPointer);
const end = () => {
if (!dragging.current) return;
dragging.current = false;
onCommit();
};
return (
<div
ref={trackRef}
className={`relative h-3 touch-none rounded-full ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
onPointerDown={(event) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
(event.currentTarget.querySelector('[role="slider"]') as HTMLElement | null)?.focus({ preventScroll: true });
dragging.current = true;
fromPointer(event.clientX);
}}
onPointerMove={(event) => {
if (dragging.current) throttled(event.clientX);
}}
onPointerUp={end}
onPointerCancel={end}
onLostPointerCapture={end}
>
{checker && <span aria-hidden="true" className="absolute inset-0 rounded-full bg-[repeating-conic-gradient(#d4d4d8_0%_25%,#fff_0%_50%)] bg-[length:8px_8px] dark:bg-[repeating-conic-gradient(#3f3f46_0%_25%,#27272a_0%_50%)]" />}
<span aria-hidden="true" className="absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.1)]" style={{ background }} />
<div
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={Math.round(value)}
aria-valuetext={valueText}
aria-orientation="horizontal"
aria-disabled={disabled || undefined}
onKeyDown={(event) => {
if (disabled) return;
const big = max / 10;
const delta: Record<string, number> = {
ArrowRight: event.shiftKey ? big : 1,
ArrowUp: event.shiftKey ? big : 1,
ArrowLeft: event.shiftKey ? -big : -1,
ArrowDown: event.shiftKey ? -big : -1,
PageUp: big,
PageDown: -big,
Home: -max,
End: max,
};
if (!(event.key in delta)) return;
event.preventDefault();
onChange(clamp(Math.round(value) + delta[event.key], 0, max));
onCommit();
}}
className="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.25),0_1px_3px_rgb(0_0_0/0.3)] outline-none ring-[var(--cp-accent)] ring-offset-2 ring-offset-white focus-visible:ring-2 dark:ring-offset-[#131619]"
style={{ left: `calc(8px + (100% - 16px) * ${value / max})`, background: thumbColor }}
/>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Color picker */
/* -------------------------------------------------------------------------- */
export interface ColorPickerProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children"> {
/** Accessible name of the whole picker (and prefix for its parts). */
label?: string;
/** Current colour as hex (controlled). Any format parseColor accepts is read. */
value?: string;
/** Initial colour (uncontrolled). */
defaultValue?: string;
/** Called with "#RRGGBB" (or "#RRGGBBAA" when alpha < 1) on every change. */
onValueChange?: (hex: string) => void;
/** Called when a drag ends and after keyboard, text, swatch and eyedropper changes. */
onValueCommit?: (hex: string) => void;
/** Show an opacity slider and allow 8-digit hex output. */
alpha?: boolean;
/** Format of the text field (users can switch it). */
format?: ColorFormat;
/** Show the text field and format switcher. */
showInput?: boolean;
/** Preset colours shown as buttons. Pass [] to hide. */
swatches?: string[];
/** Show an eyedropper button where the EyeDropper API exists (Chromium desktop). */
eyeDropper?: boolean;
/** Height of the saturation / brightness area in px. */
areaHeight?: number;
/** Focus ring and active swatch colour. */
accent?: string;
disabled?: boolean;
/** Submits the hex value with a form under this name. */
name?: string;
}
const DEFAULT_SWATCHES = ["#FF7448", "#B6F3D1", "#F5B83D", "#E5484D", "#2FBF71", "#3B82F6", "#18181B", "#F5F3EE"];
const FALLBACK: Hsva = { h: 15, s: 0.72, v: 1, a: 1 };
/**
* An accessible colour picker: a 2D saturation / brightness area, hue and optional alpha sliders,
* a validated hex / RGB / HSL field, swatches and the native eyedropper where available. Colour is
* kept as HSVA internally so the hue survives greys; all conversions are inline.
*/
export function ColorPicker({
label = "Colour",
value: valueProp,
defaultValue = "#FF7448",
onValueChange,
onValueCommit,
alpha = false,
format = "hex",
showInput = true,
swatches = DEFAULT_SWATCHES,
eyeDropper = true,
areaHeight = 168,
accent = "#FF7448",
disabled = false,
name,
className = "",
style,
...props
}: ColorPickerProps) {
const id = React.useId();
const errorId = `${id}-error`;
const canEyeDrop = useEyeDropperSupport() && eyeDropper;
const [color, setColor] = React.useState<Hsva>(() => parseColor(valueProp ?? defaultValue) ?? FALLBACK);
// Controlled: re-sync when the parent passes a different colour (keeping our hue for greys).
const [syncedValue, setSyncedValue] = React.useState(valueProp);
if (valueProp !== syncedValue) {
setSyncedValue(valueProp);
const parsed = valueProp === undefined ? null : parseColor(valueProp, color);
if (parsed && toHex(parsed) !== toHex(color)) setColor(parsed);
}
const [fmt, setFmt] = React.useState<ColorFormat>(format);
const [syncedFormat, setSyncedFormat] = React.useState(format);
if (format !== syncedFormat) {
setSyncedFormat(format);
setFmt(format);
}
const [draft, setDraft] = React.useState<string | null>(null);
const areaRef = React.useRef<HTMLDivElement>(null);
const areaThumbRef = React.useRef<HTMLDivElement>(null);
const areaDragging = React.useRef(false);
const latest = React.useRef(color);
const effective: Hsva = alpha ? color : { ...color, a: 1 };
const hex = toHex(effective);
const opaqueHex = toHex({ ...color, a: 1 });
const pureHue = toHex({ h: color.h, s: 1, v: 1, a: 1 });
// Handlers read the latest colour from a ref so rAF-batched drags never use a stale value.
React.useEffect(() => {
latest.current = color;
}, [color]);
const update = (next: Hsva) => {
const normalised = alpha ? next : { ...next, a: 1 };
const previous = latest.current;
latest.current = normalised;
if (normalised.h === previous.h && normalised.s === previous.s && normalised.v === previous.v && normalised.a === previous.a) return;
setColor(normalised);
const nextHex = toHex(normalised);
if (nextHex !== toHex(alpha ? previous : { ...previous, a: 1 })) onValueChange?.(nextHex);
};
const commit = () => onValueCommit?.(toHex(latest.current));
/* ------------------------------ 2D area input ------------------------------ */
const areaFromPointer = ([clientX, clientY]: [number, number]) => {
const rect = areaRef.current?.getBoundingClientRect();
if (!rect || !rect.width || !rect.height) return;
update({
...latest.current,
s: clamp((clientX - rect.left) / rect.width, 0, 1),
v: 1 - clamp((clientY - rect.top) / rect.height, 0, 1),
});
};
const throttledArea = useFrameThrottle(areaFromPointer);
const endAreaDrag = () => {
if (!areaDragging.current) return;
areaDragging.current = false;
commit();
};
const handleAreaKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const unit = event.shiftKey ? 0.1 : 0.01;
let { s, v } = color;
switch (event.key) {
case "ArrowRight":
s += unit;
break;
case "ArrowLeft":
s -= unit;
break;
case "ArrowUp":
v += unit;
break;
case "ArrowDown":
v -= unit;
break;
case "PageUp":
v += 0.1;
break;
case "PageDown":
v -= 0.1;
break;
case "Home":
s = 0;
break;
case "End":
s = 1;
break;
default:
return;
}
event.preventDefault();
// Round to whole percentages so keyboard steps land on clean values.
update({ ...color, s: clamp(Math.round(s * 100) / 100, 0, 1), v: clamp(Math.round(v * 100) / 100, 0, 1) });
commit();
};
/* ------------------------------- Text field -------------------------------- */
const formatted = formatColor(effective, fmt, alpha);
const draftValid = draft === null || parseColor(draft, color) !== null;
const applyText = (text: string) => {
const parsed = parseColor(text, color);
if (!parsed) return false;
update(parsed);
return true;
};
const pickWithEyeDropper = async () => {
const Ctor = (window as unknown as { EyeDropper?: EyeDropperConstructor }).EyeDropper;
if (!Ctor) return;
try {
const result = await new Ctor().open();
const parsed = parseColor(result.sRGBHex, color);
if (parsed) {
update({ ...parsed, a: color.a });
commit();
}
} catch {
// The user pressed Escape: nothing to do.
}
};
const sPct = Math.round(color.s * 100);
const vPct = Math.round(color.v * 100);
const checker =
"bg-[repeating-conic-gradient(#d4d4d8_0%_25%,#fff_0%_50%)] bg-[length:8px_8px] dark:bg-[repeating-conic-gradient(#3f3f46_0%_25%,#27272a_0%_50%)]";
return (
<div
role="group"
aria-label={label}
className={`flex w-full max-w-[18rem] select-none flex-col gap-3 ${disabled ? "pointer-events-none opacity-50" : ""} ${className}`}
style={{ ["--cp-accent" as string]: accent, ...style }}
{...props}
>
{/* Saturation (x) / brightness (y) area. */}
<div
ref={areaRef}
className="relative touch-none rounded-lg shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08)]"
style={{
height: areaHeight,
backgroundColor: pureHue,
backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
cursor: disabled ? "not-allowed" : "crosshair",
}}
onPointerDown={(event) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
areaThumbRef.current?.focus({ preventScroll: true });
areaDragging.current = true;
areaFromPointer([event.clientX, event.clientY]);
}}
onPointerMove={(event) => {
if (areaDragging.current) throttledArea([event.clientX, event.clientY]);
}}
onPointerUp={endAreaDrag}
onPointerCancel={endAreaDrag}
onLostPointerCapture={endAreaDrag}
>
<div
ref={areaThumbRef}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${label}: saturation and brightness`}
aria-roledescription="2D slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={sPct}
aria-valuetext={`Saturation ${sPct}%, brightness ${vPct}%`}
aria-describedby={`${id}-area-help`}
aria-disabled={disabled || undefined}
onKeyDown={handleAreaKey}
className="absolute 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.3),0_2px_6px_rgb(0_0_0/0.35)] outline-none ring-[var(--cp-accent)] ring-offset-2 ring-offset-white focus-visible:ring-2 dark:ring-offset-[#131619]"
style={{ left: `${color.s * 100}%`, top: `${(1 - color.v) * 100}%`, background: opaqueHex }}
/>
<span id={`${id}-area-help`} className="sr-only">
Left and right arrows change saturation, up and down arrows change brightness. Hold Shift for larger steps.
</span>
</div>
<div className="flex items-center gap-3">
{/* Live preview chip (decorative: the text field and sliders carry the value). */}
<span aria-hidden="true" className={`relative size-9 shrink-0 overflow-hidden rounded-full ${checker}`}>
<span className="absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.14)]" style={{ background: hex }} />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<ChannelSlider
label={`${label}: hue`}
value={color.h}
max={360}
valueText={`${Math.round(color.h)}°, ${hueName(color.h)}`}
background="linear-gradient(to right, #f00 0%, #ff0 16.67%, #0f0 33.33%, #0ff 50%, #00f 66.67%, #f0f 83.33%, #f00 100%)"
thumbColor={pureHue}
disabled={disabled}
onChange={(h) => update({ ...latest.current, h: clamp(h, 0, 360) })}
onCommit={commit}
/>
{alpha && (
<ChannelSlider
label={`${label}: opacity`}
value={color.a * 100}
max={100}
valueText={`${Math.round(color.a * 100)}% opacity`}
background={`linear-gradient(to right, transparent, ${opaqueHex})`}
thumbColor={hex}
checker
disabled={disabled}
onChange={(a) => update({ ...latest.current, a: clamp(a / 100, 0, 1) })}
onCommit={commit}
/>
)}
</div>
{canEyeDrop && (
<button
type="button"
onClick={pickWithEyeDropper}
aria-label="Pick a colour from the screen"
className="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-zinc-900/10 text-zinc-600 transition-colors hover:bg-zinc-900/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cp-accent)] dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/5 dark:hover:text-white"
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m13.5 6.5 4 4" />
<path d="M16.6 3.4a2 2 0 0 1 2.8 0l1.2 1.2a2 2 0 0 1 0 2.8L18 10l-4-4 2.6-2.6Z" />
<path d="M14.5 7.5 5 17v2h2l9.5-9.5" />
</svg>
</button>
)}
</div>
{showInput && (
<div className="flex flex-col gap-1">
<div className="flex h-9 items-stretch overflow-hidden rounded-md border border-zinc-900/15 bg-white focus-within:border-[var(--cp-accent)] dark:border-white/15 dark:bg-white/[0.04]">
<select
value={fmt}
onChange={(event) => {
setFmt(event.target.value as ColorFormat);
setDraft(null);
}}
aria-label={`${label}: text format`}
className="cursor-pointer border-r border-zinc-900/10 bg-transparent px-2 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 outline-none focus-visible:bg-zinc-900/5 dark:border-white/10 dark:text-zinc-400 dark:focus-visible:bg-white/5 [&>option]:bg-white [&>option]:text-zinc-900 dark:[&>option]:bg-zinc-900 dark:[&>option]:text-zinc-100"
>
<option value="hex">Hex</option>
<option value="rgb">RGB</option>
<option value="hsl">HSL</option>
</select>
<input
type="text"
value={draft ?? formatted}
spellCheck={false}
autoComplete="off"
aria-label={`${label}: ${fmt === "hex" ? "hex" : fmt.toUpperCase()} value`}
aria-invalid={!draftValid || undefined}
aria-describedby={errorId}
onChange={(event) => {
setDraft(event.target.value);
applyText(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (draft !== null && applyText(draft)) {
setDraft(null);
commit();
}
} else if (event.key === "Escape" && draft !== null) {
event.preventDefault();
setDraft(null);
}
}}
onBlur={() => {
if (draft !== null && draftValid) commit();
setDraft(null);
}}
className="min-w-0 flex-1 bg-transparent px-2.5 font-mono text-xs tabular-nums text-zinc-900 outline-none aria-[invalid]:text-red-600 dark:text-zinc-100 dark:aria-[invalid]:text-red-400"
/>
</div>
<p id={errorId} aria-live="polite" className="min-h-4 font-mono text-[10px] tracking-[0.04em] text-red-600 dark:text-red-400">
{draftValid ? "" : fmt === "hex" ? "Use a hex colour like #FF7448" : fmt === "rgb" ? "Use rgb(255, 116, 72)" : "Use hsl(15, 100%, 64%)"}
</p>
</div>
)}
{swatches.length > 0 && (
<div role="group" aria-label={`${label}: presets`} className="flex flex-wrap gap-1.5">
{swatches.map((swatch) => {
const parsed = parseColor(swatch);
if (!parsed) return null;
const swatchHex = toHex(alpha ? parsed : { ...parsed, a: 1 });
const active = swatchHex === hex;
return (
<button
key={swatch}
type="button"
aria-label={swatchHex}
aria-pressed={active}
onClick={() => {
update(alpha ? parsed : { ...parsed, a: 1 });
commit();
setDraft(null);
}}
className={`relative size-6 overflow-hidden rounded-md outline-none transition-transform duration-150 hover:scale-110 focus-visible:ring-2 focus-visible:ring-[var(--cp-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:focus-visible:ring-offset-[#131619] ${checker} ${
active ? "ring-2 ring-[var(--cp-accent)] ring-offset-2 ring-offset-white dark:ring-offset-[#131619]" : ""
}`}
>
<span aria-hidden="true" className="absolute inset-0 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.14)]" style={{ background: swatchHex }} />
</button>
);
})}
</div>
)}
{name !== undefined && <input type="hidden" name={name} value={hex} />}
</div>
);
}