amplo
uicolor-picker-enginesource: ok · refreshed
Amplo Color Picker Engine
Dialect-agnostic core of the Amplo color picker: the OKLCH state engine (types, conversions, gamut math, CSS Color 4 chroma reduction, WCAG + APCA contrast), the React context, and the pointer/canvas-driven parts both variants share — Root, Area, Chroma, Preview, EyeDropper, CssInput, Field, and the tooltip-agnostic GamutBadge/ContrastReadout logic. Installed automatically as a dependency of `color-picker` (Base UI) and `color-picker-radix`; you do not normally add it directly.
npx shadcn add "https://amplo.ale.design/r/color-picker-engine.json"free
Where it goes
testimonialstats
Interaction
animated-countertooltip
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
utilsbutton
Files17 · source cached 10/8/2026, 6:16:10 AM
registry/new-york/ui/fill-picker/lib/types.ts
/**
* Canonical color representation used throughout the picker.
* All values normalized; alpha is always tracked.
*/
export interface OklchColor {
/** Lightness 0..1 */
l: number;
/** Chroma 0..~0.4 (hard upper bound depends on hue + space) */
c: number;
/** Hue 0..360 (NaN when chroma === 0; treat as 0 for serialization) */
h: number;
/** Alpha 0..1 */
alpha: number;
}
export type ColorFormat =
| "hex"
| "rgb"
| "hsl"
| "hsb"
| "oklch"
| "oklab"
| "p3";
export type Gamut = "srgb" | "p3" | "rec2020";
export interface ContrastResult {
/** WCAG 2.1 contrast ratio, 1..21 */
wcag: number;
/** WCAG 2.1 levels passed against the supplied background */
wcagLevel: { aaNormal: boolean; aaLarge: boolean; aaaNormal: boolean; aaaLarge: boolean };
/** APCA Lc value, signed, typical magnitude 0..108 */
apca: number;
}
export interface GamutInfo {
inSrgb: boolean;
inP3: boolean;
inRec2020: boolean;
}
registry/new-york/ui/fill-picker/lib/color.ts
import {
parse as culoriParse,
converter,
formatHex,
formatHex8,
formatRgb,
toGamut as culoriToGamut,
wcagContrast,
type Color,
} from "culori";
import type { ColorFormat, ContrastResult, Gamut, GamutInfo, OklchColor } from "./types";
const toOklch = converter("oklch");
const toRgb = converter("rgb");
const toHsl = converter("hsl");
const toHsv = converter("hsv");
const toOklab = converter("oklab");
const toP3 = converter("p3");
const GAMUT_EPSILON = 1e-4;
/** Chroma / lightness-edge threshold below which hue is meaningless. */
const ACHROMATIC_EPS = 1e-4;
function channelsInRange(c: { r?: number; g?: number; b?: number } | undefined): boolean {
if (!c) return false;
const r = c.r ?? 0;
const g = c.g ?? 0;
const b = c.b ?? 0;
return (
r >= -GAMUT_EPSILON && r <= 1 + GAMUT_EPSILON &&
g >= -GAMUT_EPSILON && g <= 1 + GAMUT_EPSILON &&
b >= -GAMUT_EPSILON && b <= 1 + GAMUT_EPSILON
);
}
const toRec2020 = converter("rec2020");
function isInSrgb(ok: { mode: "oklch"; l: number; c: number; h: number; alpha: number }) {
return channelsInRange(toRgb(ok));
}
function isInP3(ok: { mode: "oklch"; l: number; c: number; h: number; alpha: number }) {
return channelsInRange(toP3(ok));
}
function isInRec2020(ok: { mode: "oklch"; l: number; c: number; h: number; alpha: number }) {
return channelsInRange(toRec2020(ok));
}
export interface ParsedColor {
color: OklchColor;
/**
* True when the source string carried no OKLCH-scale hue — an achromatic
* hex/rgb/hsl/p3 where the round-trip loses hue and `color.h` is a
* defaulted 0 or conversion noise (a p3 gray lands on c ≈ 1e-16, h 180).
* False whenever the hue is meaningful: chromatic colors, and OKLCH
* strings that author a hue even at zero chroma.
*/
hueMissing: boolean;
}
/**
* Parse a CSS color string into the canonical OKLCH form, reporting whether
* the hue survived the round-trip. Returns null when the string can't be
* parsed.
*/
export function parseColorDetailed(input: string): ParsedColor | null {
if (!input || typeof input !== "string") return null;
const parsed = culoriParse(input.trim());
if (!parsed) return null;
const oklch = toOklch(parsed);
if (!oklch) return null;
// Only OKLCH strings can author a hue at zero chroma; any other space's
// near-zero chroma is a neutral whose finite hue is float noise.
const hueMissing =
!Number.isFinite(oklch.h) ||
(parsed.mode !== "oklch" && (oklch.c ?? 0) <= ACHROMATIC_EPS);
return {
color: {
l: clamp(oklch.l ?? 0, 0, 1),
c: Math.max(oklch.c ?? 0, 0),
h: hueMissing ? 0 : wrapHue(oklch.h as number),
alpha: oklch.alpha ?? 1,
},
hueMissing,
};
}
/**
* Parse a CSS color string into the canonical OKLCH form.
* Returns null when the string can't be parsed.
*/
export function parseColor(input: string): OklchColor | null {
return parseColorDetailed(input)?.color ?? null;
}
export function isValidColor(input: string): boolean {
return parseColor(input) !== null;
}
/**
* Serialize a canonical OKLCH color to a CSS string in the chosen format.
* For sRGB-targeted formats (hex/rgb/hsl/hsb) the color is gamut-mapped to sRGB first.
* For P3 the color is gamut-mapped to P3.
* OKLCH/OKLab outputs are unbounded.
*
* Numeric output is rounded (hue/percentages to 2 decimals, unit-range
* channels to 4) so strings stay readable and a parent echoing `formatted`
* back as a controlled `value` settles after one re-parse instead of
* carrying float noise like `1.1e-14%`.
*/
export function formatColor(color: OklchColor, format: ColorFormat): string {
switch (format) {
case "hex": {
const mapped = mapToGamutColor(color, "srgb");
const rgb = toRgb({ mode: "oklch", ...oklchObj(mapped) });
if (!rgb) return "#000000";
return (color.alpha < 1 ? formatHex8(rgb) : formatHex(rgb)).toUpperCase();
}
case "rgb": {
const mapped = mapToGamutColor(color, "srgb");
const rgb = toRgb({ mode: "oklch", ...oklchObj(mapped) });
return rgb ? formatRgb(rgb) : "rgb(0 0 0)";
}
case "hsl": {
const hsl = toHsl(srgbClamped(color));
if (!hsl) return "hsl(0 0% 0%)";
return `hsl(${fmtHue(hsl.h)} ${round(hsl.s * 100, 2)}% ${round(hsl.l * 100, 2)}%${fmtAlpha(color.alpha)})`;
}
case "hsb": {
// CSS has no hsb()/hsv() function. Emit culori's `color(--hsv h s v)`
// (s/v in 0..1): not paintable by browsers, but it is the one HSB
// syntax `parseColor` reads back, so the Input field round-trips.
const hsv = toHsv(srgbClamped(color));
if (!hsv) return "color(--hsv 0 0 0)";
return `color(--hsv ${fmtHue(hsv.h)} ${round(hsv.s, 4)} ${round(hsv.v, 4)}${fmtAlpha(color.alpha)})`;
}
case "oklch": {
const { l, c, h, alpha } = color;
const lStr = round(l, 4);
const cStr = round(c, 4);
const hStr = round(h, 2);
return `oklch(${lStr} ${cStr} ${hStr}${fmtAlpha(alpha)})`;
}
case "oklab": {
const lab = toOklab({ mode: "oklch", ...oklchObj(color) });
if (!lab) return "oklab(0 0 0)";
return `oklab(${round(lab.l, 4)} ${round(lab.a, 4)} ${round(lab.b, 4)}${fmtAlpha(color.alpha)})`;
}
case "p3": {
const mapped = mapToGamutColor(color, "p3");
const p3 = toP3({ mode: "oklch", ...oklchObj(mapped) });
if (!p3) return "color(display-p3 0 0 0)";
return `color(display-p3 ${round(p3.r, 4)} ${round(p3.g, 4)} ${round(p3.b, 4)}${fmtAlpha(color.alpha)})`;
}
}
}
/** Hue for hsl/hsb output: undefined (achromatic) → 0, wrapped after rounding. */
function fmtHue(h: number | undefined): number {
if (!Number.isFinite(h)) return 0;
const r = round(wrapHue(h as number), 2);
return r >= 360 ? 0 : r;
}
/** Alpha suffix for space-separated CSS syntax; empty when opaque. */
function fmtAlpha(alpha: number): string {
return alpha < 1 ? ` / ${round(alpha, 3)}` : "";
}
/** Convenience: pull bare OKLCH numeric fields. */
function oklchObj(c: OklchColor) {
return { l: c.l, c: c.c, h: c.h, alpha: c.alpha };
}
const ALL_FORMATS: ColorFormat[] = [
"hex",
"rgb",
"hsl",
"hsb",
"oklch",
"oklab",
"p3",
];
/**
* Serialize an OKLCH color to every supported output format at once.
* Useful when consumers need both the canonical lossless form (oklch) and
* a fallback string (hex) without re-running format selection.
*/
export function formatAll(color: OklchColor): Record<ColorFormat, string> {
const out = {} as Record<ColorFormat, string>;
for (const f of ALL_FORMATS) out[f] = formatColor(color, f);
return out;
}
/**
* Continuous signed distance from the target gamut surface.
* - 0 on the boundary
* - <0 inside the gamut (any channel exceeds [0,1] by the most-negative amount)
* - >0 outside the gamut (channels exceed by the most-positive amount)
*
* Drives the marching-squares boundary line. We use inline OKLab → linear RGB
* math (zero crossings unchanged from gamma-encoded RGB) so the per-grid-point
* cost stays under a microsecond — at 128² × 2 lines per OKLCH-mode repaint
* the culori path was the new perf hot spot.
*/
export function gamutSignedDistance(color: OklchColor, gamut: Gamut): number {
const lin = oklchToLinearSrgb(color.l, color.c, color.h);
const target =
gamut === "srgb"
? lin
: gamut === "p3"
? linSrgbToLinP3(lin)
: linSrgbToLinRec2020(lin);
const lo = -Math.min(target.r, target.g, target.b);
const hi = Math.max(target.r, target.g, target.b) - 1;
return Math.max(lo, hi);
}
/**
* OKLCH → linear sRGB via Björn Ottosson's OKLab matrix. Output channels can
* be negative or >1 for colors outside sRGB; that's the point — wider gamuts
* are just `linSrgbToLinP3` / `linSrgbToLinRec2020` away.
*/
export function oklchToLinearSrgb(
l: number,
c: number,
hDeg: number,
): { r: number; g: number; b: number } {
const h = (hDeg * Math.PI) / 180;
const a = c * Math.cos(h);
const b = c * Math.sin(h);
const lp = l + 0.3963377774 * a + 0.2158037573 * b;
const mp = l - 0.1055613458 * a - 0.0638541728 * b;
const sp = l - 0.0894841775 * a - 1.291485548 * b;
const L = lp * lp * lp;
const M = mp * mp * mp;
const S = sp * sp * sp;
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,
};
}
/* Linear sRGB → linear Display-P3 (CSS Color 4 reference matrix). */
export function linSrgbToLinP3(c: {
r: number;
g: number;
b: number;
}): { r: number; g: number; b: number } {
return {
r: 0.8224621 * c.r + 0.1775380 * c.g + 0.0 * c.b,
g: 0.0331942 * c.r + 0.9668058 * c.g + 0.0 * c.b,
b: 0.0170828 * c.r + 0.0723976 * c.g + 0.9105196 * c.b,
};
}
/* Linear sRGB → linear Rec.2020 (CSS Color 4 reference matrix). */
export function linSrgbToLinRec2020(c: {
r: number;
g: number;
b: number;
}): { r: number; g: number; b: number } {
return {
r: 0.6274039 * c.r + 0.3292830 * c.g + 0.0433131 * c.b,
g: 0.0690973 * c.r + 0.9195404 * c.g + 0.0113623 * c.b,
b: 0.0163914 * c.r + 0.0880133 * c.g + 0.8955953 * c.b,
};
}
/**
* Bisect for the maximum OKLCH chroma at this lightness and hue that still
* fits inside the target gamut. Returns 0 at L=0 or L=1 (those are achromatic
* by definition). Cost: ~12 iterations × 3 matrix-mults per call.
*
* `epsilon` controls the bisection's strictness — a positive value relaxes
* the in-gamut check (allows channels out by `epsilon`); zero is strict; a
* negative value shrinks the gamut. Default 0 is strict so the returned
* chroma is *guaranteed* in-gamut; the area's bead-clamp re-pin and
* culori-based `gamutInfo` (which use their own `GAMUT_EPSILON` tolerance)
* will then never tip the picked color out.
*
* The `marginalChroma` knob trims an absolute amount off the final result
* to absorb bisection precision (~5e-5) plus OKLab matrix drift. Without it,
* the result can sit on the strict surface where the round-trip via culori's
* `toGamut` and the area's hue re-pin can push it out by float ULPs.
*/
export function findMaxChroma(
l: number,
hDeg: number,
gamut: Gamut,
options: { iterations?: number; epsilon?: number; marginalChroma?: number } = {},
): number {
if (l <= 0 || l >= 1) return 0;
const iterations = options.iterations ?? 14;
const eps = options.epsilon ?? 0;
const marginalChroma = options.marginalChroma ?? 1e-3;
const inGamut = (c: number): boolean => {
const lin = oklchToLinearSrgb(l, c, hDeg);
const target =
gamut === "srgb"
? lin
: gamut === "p3"
? linSrgbToLinP3(lin)
: linSrgbToLinRec2020(lin);
return (
target.r >= -eps && target.r <= 1 + eps &&
target.g >= -eps && target.g <= 1 + eps &&
target.b >= -eps && target.b <= 1 + eps
);
};
// Find an upper bound that is definitely outside. 0.5 covers Rec.2020 reds
// (max chroma ≈ 0.4 in OKLCH); double until out, capped at 2.0 for safety.
let hi = 0.5;
while (inGamut(hi) && hi < 2) hi *= 2;
let lo = 0;
for (let i = 0; i < iterations; i++) {
const mid = (lo + hi) / 2;
if (inGamut(mid)) lo = mid;
else hi = mid;
}
return Math.max(0, lo - marginalChroma);
}
/**
* Mildly saturated OKLCH (l, c) at which an achromatic color's hue is read
* on the HSL/HSV scale. A gray's own HSL/HSV hue is undefined or float
* noise, so the slider, the channel input's H field (`lib/channels.ts`), and
* hue writes all read it off this probe on the stored OKLCH hue instead —
* sharing one constant keeps the three in agreement.
*/
export const ACHROMATIC_HUE_PROBE = { l: 0.6, c: 0.08 } as const;
function isAchromatic(color: OklchColor): boolean {
return (
color.c <= ACHROMATIC_EPS ||
color.l <= ACHROMATIC_EPS ||
color.l >= 1 - ACHROMATIC_EPS
);
}
/**
* Hue of an OKLCH color in HSL's hue scale (degrees). Used by the Hue slider
* when the active format is `hsl` so the slider position matches what the
* channel input shows — OKLCH hue and HSL hue diverge for the same color
* (red is OKLCH ~29° but HSL 0°). Achromatic colors are read through
* `ACHROMATIC_HUE_PROBE` so the slider tracks the stored hue.
*/
export function hslHue(color: OklchColor): number {
const src = isAchromatic(color) ? { ...color, ...ACHROMATIC_HUE_PROBE } : color;
const c = toHsl({ mode: "oklch", l: src.l, c: src.c, h: src.h });
return c?.h ?? color.h;
}
/**
* Hue of an OKLCH color in HSB/HSV's hue scale (degrees). See `hslHue`.
*/
export function hsbHue(color: OklchColor): number {
const src = isAchromatic(color) ? { ...color, ...ACHROMATIC_HUE_PROBE } : color;
const c = toHsv({ mode: "oklch", l: src.l, c: src.c, h: src.h });
return c?.h ?? color.h;
}
/**
* Map an output format to the gamut whose surface fills the picker's area
* and bounds the lossless saturation-preserving updates from the hue and
* lightness sliders. OKLCH and OKLab are unbounded as serialization formats
* but still need a finite picking gamut — Rec.2020 covers everything that
* fits inside `<canvas colorSpace="display-p3">` plus the slice that won't
* paint accurately on any current monitor.
*/
export function gamutFromFormat(f: ColorFormat): Gamut {
switch (f) {
case "hex":
case "rgb":
case "hsl":
case "hsb":
return "srgb";
case "p3":
return "p3";
case "oklch":
case "oklab":
return "rec2020";
}
}
/**
* Find the OKLCH "cusp" for a given hue and gamut — the (L, C) point of
* maximum chroma. Used by the `hsv-sv` Area mode to map (S, V) onto a fully
* gamut-filling square: V=1, S=1 lands on the cusp; V=1, S=0 is white;
* V=0 is black.
*
* Two-stage search: a 32-step coarse sweep over L, then a 20-step fine sweep
* around the best candidate. ~52 `findMaxChroma` calls per cusp lookup,
* which is cheap enough to call once per repaint per active hue.
*/
export function findCusp(
hDeg: number,
gamut: Gamut,
): { l: number; c: number } {
let bestL = 0.5;
let bestC = 0;
for (let i = 1; i < 32; i++) {
const l = i / 32;
const c = findMaxChroma(l, hDeg, gamut);
if (c > bestC) {
bestC = c;
bestL = l;
}
}
const lo = Math.max(0.001, bestL - 1 / 32);
const hi = Math.min(0.999, bestL + 1 / 32);
for (let i = 0; i <= 20; i++) {
const l = lo + ((hi - lo) * i) / 20;
const c = findMaxChroma(l, hDeg, gamut);
if (c > bestC) {
bestC = c;
bestL = l;
}
}
return { l: bestL, c: bestC };
}
export function gamutInfo(color: OklchColor): GamutInfo {
const ok = { mode: "oklch" as const, ...oklchObj(color) };
return {
inSrgb: isInSrgb(ok),
inP3: isInP3(ok),
inRec2020: isInRec2020(ok),
};
}
/**
* Gamut-map an OKLCH color to the target gamut using CSS Color 4's
* chroma-reduction-in-OKLCH algorithm (culori `toGamut`).
* Returns canonical OKLCH form.
*/
export function toGamut(color: OklchColor, gamut: Gamut): OklchColor {
return mapToGamutColor(color, gamut);
}
/**
* sRGB channels of `color` after gamut mapping, clamped to [0, 1]. The
* mapper tolerates `GAMUT_EPSILON` of channel slack, which near black is a
* large relative error: fed to HSL/HSV it becomes saturation in the
* millions. Channels are also snapped to 1e-6 (far below 8-bit precision):
* near white HSL saturation divides by 1 - |2L - 1| ≈ 0, so 1e-7 of
* conversion noise on #fff would otherwise read as 50%. HSL/HSV output
* strings and channel readouts read through this.
*/
export function srgbClamped(color: OklchColor) {
const rgb = toRgb({ mode: "oklch", ...oklchObj(mapToGamutColor(color, "srgb")) });
const ch = (v: number | undefined) => round(clamp(v ?? 0, 0, 1), 6);
return { mode: "rgb" as const, r: ch(rgb?.r), g: ch(rgb?.g), b: ch(rgb?.b) };
}
function mapToGamutColor(color: OklchColor, gamut: Gamut): OklchColor {
const ok = { mode: "oklch" as const, ...oklchObj(color) };
// culori's gamut test is strict, so an in-gamut color whose OKLCH
// round-trip leaves a channel at -1e-14 gets chroma-reduced, nudging it
// off its own value (#008197 → #018197). Honor the same tolerance as
// `gamutInfo` and leave such colors untouched (`srgbClamped` clamps the
// leftover slack for HSL/HSV output).
const inGamut =
gamut === "srgb" ? isInSrgb(ok) : gamut === "p3" ? isInP3(ok) : isInRec2020(ok);
if (inGamut) return color;
const targetMode = gamut === "srgb" ? "rgb" : gamut === "p3" ? "p3" : "rec2020";
const mapper = culoriToGamut(targetMode, "oklch");
const mapped = mapper(ok) as Color | undefined;
if (!mapped) return color;
const back = toOklch(mapped);
if (!back) return color;
return {
l: back.l ?? color.l,
c: Math.max(back.c ?? 0, 0),
h: Number.isFinite(back.h) ? (back.h as number) : color.h,
alpha: color.alpha,
};
}
/**
* Composite fg (with alpha) over an opaque bg in gamma-encoded sRGB — the
* same space browsers blend in by default, so the result matches what's
* painted.
*/
function compositeOnBg(fg: OklchColor, bg: OklchColor): OklchColor {
if (fg.alpha >= 1) return fg;
const fgRgb = toRgb({ mode: "oklch", ...oklchObj(fg) });
const bgRgb = toRgb({ mode: "oklch", ...oklchObj(bg) });
if (!fgRgb || !bgRgb) return fg;
const a = fg.alpha;
const out = {
mode: "rgb" as const,
r: (fgRgb.r ?? 0) * a + (bgRgb.r ?? 0) * (1 - a),
g: (fgRgb.g ?? 0) * a + (bgRgb.g ?? 0) * (1 - a),
b: (fgRgb.b ?? 0) * a + (bgRgb.b ?? 0) * (1 - a),
alpha: 1,
};
const oklch = toOklch(out)!;
return {
l: oklch.l ?? 0,
c: Math.max(oklch.c ?? 0, 0),
h: Number.isFinite(oklch.h) ? (oklch.h as number) : 0,
alpha: 1,
};
}
export function contrast(fg: OklchColor, bg: OklchColor): ContrastResult {
const composedFg = compositeOnBg(fg, { ...bg, alpha: 1 });
const fgC = toRgb({ mode: "oklch", ...oklchObj(composedFg) });
const bgC = toRgb({ mode: "oklch", ...oklchObj({ ...bg, alpha: 1 }) });
const ratio = fgC && bgC ? wcagContrast(fgC, bgC) : 1;
const safe = Number.isFinite(ratio) ? ratio : 1;
return {
wcag: round(safe, 2),
wcagLevel: {
aaNormal: safe >= 4.5,
aaLarge: safe >= 3,
aaaNormal: safe >= 7,
aaaLarge: safe >= 4.5,
},
apca: apcaContrast(composedFg, { ...bg, alpha: 1 }),
};
}
/**
* APCA Lc value per APCA-W3 0.1.9 (SACAM 0.98G).
* Reference: https://github.com/Myndex/SAPC-APCA
* Returned value is rounded to 2 decimals.
* - Positive Lc: dark text on light bg
* - Negative Lc: light text on dark bg
*/
export function apcaContrast(fg: OklchColor, bg: OklchColor): number {
const fgRgb = toRgb({ mode: "oklch", ...oklchObj(fg) });
const bgRgb = toRgb({ mode: "oklch", ...oklchObj(bg) });
if (!fgRgb || !bgRgb) return 0;
const Ytxt = sapcLuminance(fgRgb.r ?? 0, fgRgb.g ?? 0, fgRgb.b ?? 0);
const Ybg = sapcLuminance(bgRgb.r ?? 0, bgRgb.g ?? 0, bgRgb.b ?? 0);
return round(sapcContrast(Ytxt, Ybg), 2);
}
const SA98G = {
mainTRC: 2.4,
Rco: 0.2126729,
Gco: 0.7151522,
Bco: 0.072175,
normBG: 0.56,
normTXT: 0.57,
revTXT: 0.62,
revBG: 0.65,
blkThrs: 0.022,
blkClmp: 1.414,
scaleBoW: 1.14,
scaleWoB: 1.14,
loBoWoffset: 0.027,
loWoBoffset: 0.027,
deltaYmin: 0.0005,
loClip: 0.1,
};
function sapcLuminance(r: number, g: number, b: number): number {
const R = clamp(r, 0, 1) ** SA98G.mainTRC;
const G = clamp(g, 0, 1) ** SA98G.mainTRC;
const B = clamp(b, 0, 1) ** SA98G.mainTRC;
return SA98G.Rco * R + SA98G.Gco * G + SA98G.Bco * B;
}
function sapcContrast(Ytxt: number, Ybg: number): number {
const txt = Ytxt < SA98G.blkThrs
? Ytxt + (SA98G.blkThrs - Ytxt) ** SA98G.blkClmp
: Ytxt;
const bg = Ybg < SA98G.blkThrs
? Ybg + (SA98G.blkThrs - Ybg) ** SA98G.blkClmp
: Ybg;
if (Math.abs(bg - txt) < SA98G.deltaYmin) return 0;
let SAPC = 0;
let outputContrast = 0;
if (bg > txt) {
// Dark text on light background
SAPC = (bg ** SA98G.normBG - txt ** SA98G.normTXT) * SA98G.scaleBoW;
outputContrast = SAPC < SA98G.loClip ? 0 : SAPC - SA98G.loBoWoffset;
} else {
// Light text on dark background
SAPC = (bg ** SA98G.revBG - txt ** SA98G.revTXT) * SA98G.scaleWoB;
outputContrast = SAPC > -SA98G.loClip ? 0 : SAPC + SA98G.loWoBoffset;
}
return outputContrast * 100;
}
function clamp(x: number, lo: number, hi: number) {
return Math.min(Math.max(x, lo), hi);
}
function round(x: number, dp: number) {
const f = 10 ** dp;
return Math.round(x * f) / f;
}
function wrapHue(h: number) {
return ((h % 360) + 360) % 360;
}
registry/new-york/ui/fill-picker/lib/channels.ts
import { converter, type Color } from "culori";
import type { ColorFormat, OklchColor } from "./types";
import {
ACHROMATIC_HUE_PROBE,
findMaxChroma,
gamutFromFormat,
srgbClamped,
toGamut,
} from "./color";
const toOklch = converter("oklch");
const toRgb = converter("rgb");
const toHsl = converter("hsl");
const toHsv = converter("hsv");
const toOklab = converter("oklab");
const toP3 = converter("p3");
export interface ChannelDescriptor {
/** Internal key used by setColorChannel. */
key: string;
/** One- or two-letter UI label (R, G, B, H, S, L, etc.). */
label: string;
/** Current value in display units (e.g. RGB 0–255, OKLCH L 0–100). */
value: number;
min: number;
/** Hard edit bound; `Infinity` for channels unbounded in their space
* (OKLCH chroma). Clamping inputs must skip non-finite bounds. */
max: number;
/** Arrow-key step. */
step: number;
/** Shift+arrow step. */
bigStep: number;
/** Decimals to render. */
precision: number;
/** Optional unit suffix shown after the field. */
suffix?: string;
}
const ALPHA_DESCRIPTOR = (alpha: number): ChannelDescriptor => ({
key: "alpha",
label: "α",
value: round(alpha * 100, 0),
min: 0,
max: 100,
step: 1,
bigStep: 10,
precision: 0,
suffix: "%",
});
/**
* Per-format channel descriptors for the multi-field input. Hex returns an
* empty array — render hex as a single text field instead.
*/
export function colorChannels(
color: OklchColor,
format: ColorFormat,
): ChannelDescriptor[] {
switch (format) {
case "hex":
return [];
case "rgb": {
const rgb = toRgb({
mode: "oklch",
...oklchObj(toGamut(color, "srgb")),
});
const r = round((rgb?.r ?? 0) * 255, 0);
const g = round((rgb?.g ?? 0) * 255, 0);
const b = round((rgb?.b ?? 0) * 255, 0);
return [
intChannel("r", "R", r, 0, 255),
intChannel("g", "G", g, 0, 255),
intChannel("b", "B", b, 0, 255),
ALPHA_DESCRIPTOR(color.alpha),
];
}
case "hsl": {
const hsl = toHsl(srgbClamped(color));
return [
intChannel("h", "H", round(stableFormatHue(toHsl, color, hsl?.h), 0), 0, 360),
intChannel("s", "S", round((hsl?.s ?? 0) * 100, 0), 0, 100, "%"),
intChannel("l", "L", round((hsl?.l ?? 0) * 100, 0), 0, 100, "%"),
ALPHA_DESCRIPTOR(color.alpha),
];
}
case "hsb": {
const hsv = toHsv(srgbClamped(color));
return [
intChannel("h", "H", round(stableFormatHue(toHsv, color, hsv?.h), 0), 0, 360),
intChannel("s", "S", round((hsv?.s ?? 0) * 100, 0), 0, 100, "%"),
intChannel("b", "B", round((hsv?.v ?? 0) * 100, 0), 0, 100, "%"),
ALPHA_DESCRIPTOR(color.alpha),
];
}
case "oklch":
return [
intChannel("l", "L", round(color.l * 100, 0), 0, 100, "%"),
// Chroma is unbounded above at edit time (gamut limits apply at
// display via toGamut) — a finite max here would let clamping
// number fields destroy wide-gamut values.
floatChannel("c", "C", round(color.c, 3), 0, Infinity, 0.005, 0.05, 3),
intChannel("h", "H", round(color.h, 0), 0, 360),
ALPHA_DESCRIPTOR(color.alpha),
];
case "oklab": {
const lab = toOklab({ mode: "oklch", ...oklchObj(color) });
return [
intChannel("l", "L", round((lab?.l ?? color.l) * 100, 0), 0, 100, "%"),
floatChannel("a", "a", round(lab?.a ?? 0, 3), -0.5, 0.5, 0.005, 0.05, 3),
floatChannel("b", "b", round(lab?.b ?? 0, 3), -0.5, 0.5, 0.005, 0.05, 3),
ALPHA_DESCRIPTOR(color.alpha),
];
}
case "p3": {
const p3 = toP3({
mode: "oklch",
...oklchObj(toGamut(color, "p3")),
});
return [
floatChannel("r", "R", round(p3?.r ?? 0, 3), 0, 1, 0.01, 0.1, 3),
floatChannel("g", "G", round(p3?.g ?? 0, 3), 0, 1, 0.01, 0.1, 3),
floatChannel("b", "B", round(p3?.b ?? 0, 3), 0, 1, 0.01, 0.1, 3),
ALPHA_DESCRIPTOR(color.alpha),
];
}
}
}
/**
* Replace one channel's value in the active format's space and convert the
* result back to canonical OKLCH. Display-unit input (e.g. RGB 0–255, OKLCH
* L 0–100, alpha 0–100) — colorChannels and this writer agree on units.
*/
export function setColorChannel(
color: OklchColor,
format: ColorFormat,
key: string,
value: number,
): OklchColor {
// A cleared or garbage numeric field can surface as NaN; storing it would
// poison every downstream conversion.
if (!Number.isFinite(value)) return color;
if (key === "alpha") {
return { ...color, alpha: clamp(value / 100, 0, 1) };
}
switch (format) {
case "hex":
// Hex isn't channel-addressable; callers should special-case it.
return color;
case "rgb": {
const rgb = toRgb({
mode: "oklch",
...oklchObj(toGamut(color, "srgb")),
}) ?? { r: 0, g: 0, b: 0 };
const next = {
...rgb,
[key]: clamp(value / 255, 0, 1),
mode: "rgb" as const,
};
return fromCulori(next, color.alpha, color.h);
}
case "hsl": {
const hsl = toHsl({
mode: "oklch",
...oklchObj(toGamut(color, "srgb")),
}) ?? { h: 0, s: 0, l: 0 };
if (key === "h" && isAchromatic(color.l, color.c)) {
return { ...color, h: oklchHueForFormatHue("hsl", wrap(value, 360), color.h) };
}
const h = key === "h" ? wrap(value, 360) : stableFormatHue(toHsl, color, hsl.h);
const s = key === "s" ? clamp(value / 100, 0, 1) : hsl.s;
const l = key === "l" ? clamp(value / 100, 0, 1) : hsl.l;
return fromCulori({ mode: "hsl" as const, h, s, l }, color.alpha, color.h);
}
case "hsb": {
const hsv = toHsv({
mode: "oklch",
...oklchObj(toGamut(color, "srgb")),
}) ?? { h: 0, s: 0, v: 0 };
if (key === "h" && isAchromatic(color.l, color.c)) {
return { ...color, h: oklchHueForFormatHue("hsv", wrap(value, 360), color.h) };
}
const h = key === "h" ? wrap(value, 360) : stableFormatHue(toHsv, color, hsv.h);
const s = key === "s" ? clamp(value / 100, 0, 1) : hsv.s;
const v = key === "b" ? clamp(value / 100, 0, 1) : hsv.v;
return fromCulori({ mode: "hsv" as const, h, s, v }, color.alpha, color.h);
}
case "oklch": {
switch (key) {
case "l":
return { ...color, l: clamp(value / 100, 0, 1) };
case "c":
// OKLCH chroma is unbounded above; gamut limits are applied at
// display time via toGamut, never at channel-edit time.
return { ...color, c: Math.max(value, 0) };
case "h":
return { ...color, h: wrap(value, 360) };
default:
return color;
}
}
case "oklab": {
const lab = toOklab({ mode: "oklch", ...oklchObj(color) }) ?? {
l: 0,
a: 0,
b: 0,
};
const l = key === "l" ? clamp(value / 100, 0, 1) : (lab.l ?? 0);
const a = key === "a" ? clamp(value, -0.5, 0.5) : (lab.a ?? 0);
const b = key === "b" ? clamp(value, -0.5, 0.5) : (lab.b ?? 0);
return fromCulori({ mode: "oklab" as const, l, a, b }, color.alpha, color.h);
}
case "p3": {
const p3 = toP3({
mode: "oklch",
...oklchObj(toGamut(color, "p3")),
}) ?? { r: 0, g: 0, b: 0 };
const next = {
...p3,
[key]: clamp(value, 0, 1),
mode: "p3" as const,
};
return fromCulori(next, color.alpha, color.h);
}
}
}
/**
* Next color for a hue-slider commit. Shared by both `<ColorPicker.Hue>`
* variants (classic and Base UI) so the two can't drift apart.
*
* Two paths:
* - HSL/HSB — write the hue through the active format so the channel
* input's H matches the slider exactly (no OKLCH↔HSL hue drift). On an
* achromatic color this stores the latent OKLCH hue that reads back as
* `newHue` (see `oklchHueForFormatHue`).
* - everything else — rescale chroma to preserve "saturation", i.e. the
* color's chroma as a fraction of the max chroma available at
* (l, hue, gamut). Max chroma moves with hue (green has far less than
* red in P3), so preserving *absolute* chroma would walk the color out
* of the active gamut as the user scrolls. Preserving the ratio keeps
* the area bead's X position — and the gamut badge — put.
*
* `newHue` may be any real number; it is wrapped into [0, 360). A
* non-finite `newHue` returns `color` unchanged.
*/
export function setHueFromSlider(
color: OklchColor,
newHue: number,
format: ColorFormat,
): OklchColor {
if (!Number.isFinite(newHue)) return color;
const wrapped = wrap(newHue, 360);
if (format === "hsl" || format === "hsb") {
return setColorChannel(color, format, "h", wrapped);
}
const gamut = gamutFromFormat(format);
const oldMaxC = findMaxChroma(color.l, color.h, gamut);
const newMaxC = findMaxChroma(color.l, wrapped, gamut);
const saturation = oldMaxC > 1e-6 ? color.c / oldMaxC : 0;
return { ...color, h: wrapped, c: saturation * newMaxC };
}
const ACHROMATIC_EPS = 1e-4;
function isAchromatic(l: number, c: number): boolean {
return (
c <= ACHROMATIC_EPS || l <= ACHROMATIC_EPS || l >= 1 - ACHROMATIC_EPS
);
}
/**
* Convert an edited culori color back to canonical OKLCH, preserving
* `fallbackHue` (the pre-edit hue) when the result is achromatic — there
* culori's hue is undefined or numerically meaningless, and storing it
* would destroy the user's hue (e.g. HSL s → 0 → 50 snapping blue to red).
* Chroma is the only axis allowed to be lossy; hue must round-trip.
*/
function fromCulori(c: Color, alpha: number, fallbackHue: number): OklchColor {
const ok = toOklch(c);
if (!ok) return { l: 0, c: 0, h: fallbackHue, alpha };
const chroma = Math.max(ok.c ?? 0, 0);
const l = ok.l ?? 0;
return {
l,
c: chroma,
h:
isAchromatic(l, chroma) || !Number.isFinite(ok.h)
? fallbackHue
: (ok.h as number),
alpha,
};
}
/**
* Hue of `color` in the target format's own hue scale (HSL/HSV degrees),
* stable at the achromatic point: when chroma is ~0 the direct conversion
* yields an undefined or garbage hue, so probe a slightly-saturated color
* on the same OKLCH hue instead. Keeps the H field and re-saturation edits
* anchored to the hue the user last had.
*/
function stableFormatHue(
convert: (c: Color) => { h?: number } | undefined,
color: OklchColor,
raw: number | undefined,
): number {
if (!isAchromatic(color.l, color.c) && Number.isFinite(raw)) {
return raw as number;
}
const probe = convert({ mode: "oklch", ...ACHROMATIC_HUE_PROBE, h: color.h });
return probe?.h ?? color.h;
}
/**
* Inverse of the achromatic probe in `stableFormatHue`: the OKLCH hue whose
* probe color reads `target` on the HSL/HSV hue scale. An achromatic color
* has no format hue to write through, so an H edit stores this latent OKLCH
* hue instead — otherwise the edit is a silent no-op. Fixed-point iteration:
* keep the probe's s/l (or s/v) and swap in the target hue; converges to
* well under 0.01° in a few steps because s/l barely move with hue.
*/
function oklchHueForFormatHue(
mode: "hsl" | "hsv",
target: number,
guess: number,
): number {
let h = guess;
for (let i = 0; i < 6; i++) {
const probe = { mode: "oklch" as const, ...ACHROMATIC_HUE_PROBE, h };
const back =
mode === "hsl"
? toOklch({ ...toHsl(probe), h: target })
: toOklch({ ...toHsv(probe), h: target });
if (!back || !Number.isFinite(back.h)) break;
h = wrap(back.h as number, 360);
}
return h;
}
function oklchObj(c: OklchColor) {
return { l: c.l, c: c.c, h: c.h, alpha: c.alpha };
}
function intChannel(
key: string,
label: string,
value: number,
min: number,
max: number,
suffix?: string,
): ChannelDescriptor {
return {
key,
label,
value,
min,
max,
step: 1,
bigStep: 10,
precision: 0,
suffix,
};
}
function floatChannel(
key: string,
label: string,
value: number,
min: number,
max: number,
step: number,
bigStep: number,
precision: number,
suffix?: string,
): ChannelDescriptor {
return { key, label, value, min, max, step, bigStep, precision, suffix };
}
function clamp(v: number, min: number, max: number) {
return v < min ? min : v > max ? max : v;
}
function wrap(v: number, mod: number) {
return ((v % mod) + mod) % mod;
}
function round(v: number, precision: number) {
const m = 10 ** precision;
return Math.round(v * m) / m;
}
registry/new-york/ui/fill-picker/lib/constants.ts
/**
* Shared alpha-transparency checkerboard backgrounds (inline SVG data URIs).
* Two sizes on purpose: 12px tiles for large surfaces (alpha slider, preview,
* gradient area/overlay), 8px for compact chips (swatches, stop rows,
* contrast readout). Pair the SM variant with `backgroundSize: "8px 8px"`.
*/
export const CHECKERBOARD_LG =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><rect width='6' height='6' fill='%23ccc'/><rect x='6' y='6' width='6' height='6' fill='%23ccc'/></svg>\")";
export const CHECKERBOARD_SM =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><rect width='4' height='4' fill='%23ccc'/><rect x='4' y='4' width='4' height='4' fill='%23ccc'/></svg>\")";
/**
* Edge treatment for sample chips — swatches, gradient presets, the preview.
*
* These used to carry `border border-border`, which is wrong for a color
* sample in two ways: an outer border steals a pixel from the color itself,
* and because it paints a fixed opaque grey *outside* the sample it reads as
* a halo — glaring around a dark chip on a light surface, and vanishing
* entirely around a dark chip on a dark surface, which is exactly backwards
* from where an edge is needed.
*
* Instead draw a translucent hairline *inside* the chip, as an `::after`
* overlay so it sits above whatever fills the tile (the swatch's color span,
* the preview's stacked fg/bg layers). Being translucent, it disappears into
* a saturated or dark color — which is correct, since those already separate
* from the surface — and only asserts itself on the pale samples that would
* otherwise dissolve into it. The tone flips per theme so a near-white chip
* stays defined on a light surface and a near-black chip on a dark one.
*
* Apply to a `relative` element; the chip's own `rounded-*` is inherited.
*/
export const SAMPLE_EDGE =
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:content-[''] " +
"after:shadow-[inset_0_0_0_1px_rgb(0_0_0/0.16)] dark:after:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.18)]";
registry/new-york/ui/fill-picker/lib/swatch-presets.ts
import type { OklchColor } from "./types";
/** Default swatch presets shared by both Swatches variants. */
export const DEFAULT_SWATCH_PRESETS = [
"oklch(0.95 0 0)",
"oklch(0.75 0 0)",
"oklch(0.5 0 0)",
"oklch(0.25 0 0)",
"oklch(0.05 0 0)",
"oklch(0.7 0.18 30)",
"oklch(0.7 0.18 90)",
"oklch(0.7 0.18 150)",
"oklch(0.7 0.18 210)",
"oklch(0.7 0.18 270)",
];
/**
* Compare a preset to the current color in canonical OKLCH form so the
* active ring shows regardless of the active output format (a hex-string
* comparison would never match when the format is anything but `hex`).
* Achromatic colors have an undefined hue; the hue check is skipped when
* either side has near-zero chroma so swatches like `oklch(0.5 0 0)` match
* the current gray regardless of its drifted hue.
*/
export function isSameSwatchColor(
preset: OklchColor,
color: OklchColor,
): boolean {
if (Math.abs(preset.l - color.l) >= 1e-3) return false;
if (Math.abs(preset.c - color.c) >= 1e-3) return false;
if (Math.abs(preset.alpha - color.alpha) >= 1e-3) return false;
if (preset.c < 1e-3 || color.c < 1e-3) return true;
const d = (((preset.h - color.h) % 360) + 360) % 360;
const wrapped = d > 180 ? 360 - d : d;
return wrapped < 0.1;
}
registry/new-york/ui/fill-picker/hooks/use-color-picker.ts
"use client";
import * as React from "react";
import {
parseColor,
parseColorDetailed,
formatAll,
gamutFromFormat,
gamutInfo,
contrast,
toGamut,
} from "../lib/color";
import type {
ColorFormat,
ContrastResult,
GamutInfo,
OklchColor,
} from "../lib/types";
export type ColorComponent = "l" | "c" | "h" | "alpha";
export interface UseColorPickerProps {
/** Controlled color value (string or canonical OklchColor). */
value?: string | OklchColor;
/** Initial color when uncontrolled. */
defaultValue?: string | OklchColor;
/**
* Fires whenever the color changes.
* - `color`: canonical OKLCH form (lossless source of truth).
* - `formatted`: the active output format string (governed by `format`).
* - `formats`: every supported format pre-serialized — use `formats.hex`
* when you need a fallback alongside the canonical `formats.oklch`.
*/
onValueChange?: (
color: OklchColor,
formatted: string,
formats: Record<ColorFormat, string>,
) => void;
/** Active output format. */
format?: ColorFormat;
/** Initial format when uncontrolled. */
defaultFormat?: ColorFormat;
onFormatChange?: (format: ColorFormat) => void;
/**
* Color spaces (output formats) the picker exposes. Restricts the
* FormatSwitcher tabs and the default format. Defaults to all formats.
*/
formats?: ColorFormat[];
/** Background used for contrast metrics. */
backgroundColor?: string | OklchColor;
}
export interface ColorPickerState {
color: OklchColor;
format: ColorFormat;
/** Active format string (mirrors `format`). */
formatted: string;
/** Allowed output formats (the set restricting the FormatSwitcher). */
formats: ColorFormat[];
/** Pre-serialized strings for every supported format. */
formatStrings: Record<ColorFormat, string>;
setColor: (next: string | OklchColor) => void;
setComponent: (key: ColorComponent, value: number) => void;
adjustComponent: (key: ColorComponent, delta: number) => void;
setFormat: (f: ColorFormat) => void;
setFromString: (s: string) => boolean;
gamut: GamutInfo;
contrast: ContrastResult;
background: OklchColor;
}
const ALL_FORMATS: ColorFormat[] = ["hex", "rgb", "hsl", "hsb", "oklch", "oklab", "p3"];
const BLACK: OklchColor = { l: 0, c: 0, h: 0, alpha: 1 };
const WHITE: OklchColor = { l: 1, c: 0, h: 0, alpha: 1 };
function coerce(input: string | OklchColor | undefined, fallback: OklchColor): OklchColor {
if (!input) return fallback;
if (typeof input === "string") {
return parseColor(input) ?? fallback;
}
// Strings are hue-wrapped by the parser; normalize objects the same way.
return { ...input, h: wrapHue(input.h) };
}
function clamp(x: number, lo: number, hi: number) {
return Math.min(Math.max(x, lo), hi);
}
function wrapHue(h: number) {
const m = h % 360;
return m < 0 ? m + 360 : m;
}
const HUE_EPS = 1e-4;
function isAchromatic(c: OklchColor): boolean {
return c.c <= HUE_EPS || c.l <= HUE_EPS || c.l >= 1 - HUE_EPS;
}
function applyComponent(c: OklchColor, key: ColorComponent, raw: number): OklchColor {
// NaN/Infinity (e.g. a cleared number field) would poison every derived
// conversion; ignore the write instead.
if (!Number.isFinite(raw)) return c;
switch (key) {
case "l":
return { ...c, l: clamp(raw, 0, 1) };
case "c":
return { ...c, c: Math.max(raw, 0) };
case "h":
return { ...c, h: wrapHue(raw) };
case "alpha":
return { ...c, alpha: clamp(raw, 0, 1) };
}
}
export function useColorPicker(props: UseColorPickerProps = {}): ColorPickerState {
const {
value: controlledValue,
defaultValue,
onValueChange,
format: controlledFormat,
defaultFormat = "p3",
onFormatChange,
formats: formatsProp,
backgroundColor,
} = props;
const formats = React.useMemo<ColorFormat[]>(
() => (formatsProp && formatsProp.length > 0 ? formatsProp : ALL_FORMATS),
[formatsProp],
);
const initialFormat = formats.includes(defaultFormat) ? defaultFormat : formats[0];
const [internalColor, setInternalColor] = React.useState<OklchColor>(() =>
coerce(defaultValue, BLACK),
);
const [internalFormat, setInternalFormat] = React.useState<ColorFormat>(initialFormat);
const isControlledColor = controlledValue !== undefined;
const isControlledFormat = controlledFormat !== undefined;
// Hue is undefined for achromatic colors (c=0, pure black, pure white) so
// any string round-trip through hex/rgb erases it. Remember the last hue
// observed on a chromatic, mid-lightness color and substitute it back when
// the resolved color lands on an achromatic edge — keeps the area picker
// from snapping the hue to 0 when the user drags toward gray/black/white.
// Seeded from the initial color state rather than re-parsing
// `defaultValue` on every render (every drag frame).
const lastGoodHueRef = React.useRef<number>(internalColor.h || 0);
const isControlledStringInput =
isControlledColor && typeof controlledValue === "string";
const controlledParsed = React.useMemo(
() =>
isControlledStringInput
? parseColorDetailed(controlledValue as string)
: null,
[controlledValue, isControlledStringInput],
);
const rawColor = React.useMemo(
() =>
isControlledStringInput
? (controlledParsed?.color ?? BLACK)
: isControlledColor
? coerce(controlledValue, BLACK)
: internalColor,
[
controlledParsed,
controlledValue,
internalColor,
isControlledColor,
isControlledStringInput,
],
);
// A hue is "good" (worth remembering) when it's meaningful: any chromatic
// color, or a string that authored a hue even at zero chroma (OKLCH can).
const controlledHueAuthored = controlledParsed
? !controlledParsed.hueMissing
: false;
React.useLayoutEffect(() => {
if (!isAchromatic(rawColor) || controlledHueAuthored) {
lastGoodHueRef.current = rawColor.h;
}
}, [controlledHueAuthored, rawColor]);
// Only substitute on string-controlled inputs whose parse actually lost
// the hue (achromatic hex/rgb/hsl round-trips). Strings that author a hue
// (e.g. `oklch(0.5 0 90)`), object-controlled, and uncontrolled state all
// carry the hue verbatim — including explicit user assignments like
// setComponent("h", 0) on a black/white color.
// Reading the prior committed hue is intentional: layout effects keep the
// ref current before input can arrive, while avoiding a render-time write.
// eslint-disable-next-line react-hooks/refs
const preservedHue = lastGoodHueRef.current;
const color = React.useMemo<OklchColor>(
() =>
isControlledStringInput && (controlledParsed?.hueMissing ?? true)
? { ...rawColor, h: preservedHue }
: rawColor,
[
controlledParsed?.hueMissing,
isControlledStringInput,
preservedHue,
rawColor,
],
);
const format = isControlledFormat ? controlledFormat! : internalFormat;
const background = React.useMemo(
() => coerce(backgroundColor, WHITE),
[backgroundColor],
);
const formatStrings = React.useMemo(() => formatAll(color), [color]);
const formatted = formatStrings[format];
const gamut = React.useMemo(() => gamutInfo(color), [color]);
const contrastResult = React.useMemo(
() => contrast(color, background),
[color, background],
);
const commitColor = React.useCallback(
(next: OklchColor, formatOverride?: ColorFormat) => {
// NaN never equals itself, so storing it would spin the render-time
// sync in consumers; drop non-finite colors at the boundary.
if (![next.l, next.c, next.h, next.alpha].every(Number.isFinite)) return;
if (!isControlledColor) setInternalColor(next);
if (onValueChange) {
const all = formatAll(next);
onValueChange(next, all[formatOverride ?? format], all);
}
},
[format, isControlledColor, onValueChange],
);
// Commit a string input, re-pinning the remembered hue when the parse
// lost it (achromatic hex/rgb/hsl) — otherwise a gray commit would store
// the defaulted h: 0 and later re-saturation would snap to red.
const commitString = React.useCallback(
(s: string): boolean => {
const parsed = parseColorDetailed(s);
if (!parsed) return false;
const next = parsed.hueMissing
? { ...parsed.color, h: lastGoodHueRef.current }
: parsed.color;
commitColor(next);
return true;
},
[commitColor],
);
const setColor = React.useCallback(
(next: string | OklchColor) => {
if (typeof next === "string") {
commitString(next);
return;
}
commitColor(next);
},
[commitColor, commitString],
);
const setComponent = React.useCallback(
(key: ColorComponent, val: number) => {
commitColor(applyComponent(color, key, val));
},
[color, commitColor],
);
const adjustComponent = React.useCallback(
(key: ColorComponent, delta: number) => {
const current =
key === "l" ? color.l : key === "c" ? color.c : key === "h" ? color.h : color.alpha;
commitColor(applyComponent(color, key, current + delta));
},
[color, commitColor],
);
const setFormat = React.useCallback(
(f: ColorFormat) => {
// Switching formats is also a switch of *picking* gamut. If the current
// OKLCH state lives outside the new format's gamut (e.g. user authored
// a wide P3 chroma in OKLCH mode, then toggled to hex), the displayed
// string would be gamut-mapped at format time but the underlying state
// — and the gamut badge — would still report out-of-gamut. Clamp on
// the way in so state and display agree. Hue is pinned per the picker's
// "chroma is the only lossy axis" invariant.
const targetGamut = gamutFromFormat(f);
const info = gamutInfo(color);
const alreadyIn =
targetGamut === "srgb"
? info.inSrgb
: targetGamut === "p3"
? info.inP3
: info.inRec2020;
if (!alreadyIn) {
const targetHue = color.h;
const clamped = toGamut(color, targetGamut);
// Pass the next format explicitly so the clamp notification never
// emits using the closure's previous format.
commitColor({ ...clamped, h: targetHue }, f);
}
if (!isControlledFormat) setInternalFormat(f);
onFormatChange?.(f);
},
[color, commitColor, isControlledFormat, onFormatChange],
);
const setFromString = React.useCallback(
(s: string) => commitString(s),
[commitString],
);
return {
color,
format,
formatted,
formats,
formatStrings,
setColor,
setComponent,
adjustComponent,
setFormat,
setFromString,
gamut,
contrast: contrastResult,
background,
};
}
registry/new-york/ui/fill-picker/context.tsx
"use client";
import * as React from "react";
import type { ColorPickerState } from "./hooks/use-color-picker";
export const ColorPickerContext = React.createContext<ColorPickerState | null>(null);
export function useColorPickerContext(): ColorPickerState {
const ctx = React.useContext(ColorPickerContext);
if (!ctx) {
throw new Error(
"ColorPicker.* parts must be rendered inside <ColorPicker> (or <ColorPicker.Root>)",
);
}
return ctx;
}
registry/new-york/ui/fill-picker/parts/root.tsx
"use client";
import * as React from "react";
import { ColorPickerContext } from "../context";
import { useColorPicker, type UseColorPickerProps } from "../hooks/use-color-picker";
import { cn } from "@/lib/utils";
export interface RootProps
extends UseColorPickerProps,
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
/** When true the picker is rendered without children using the default layout. */
asChild?: boolean;
}
export const Root = React.forwardRef<HTMLDivElement, RootProps>(function Root(
{
value,
defaultValue,
onValueChange,
format,
defaultFormat,
onFormatChange,
formats,
backgroundColor,
className,
children,
...rest
},
ref,
) {
const state = useColorPicker({
value,
defaultValue,
onValueChange,
format,
defaultFormat,
onFormatChange,
formats,
backgroundColor,
});
return (
<ColorPickerContext.Provider value={state}>
<div
ref={ref}
data-slot="color-picker"
className={cn(
"flex w-full max-w-[280px] flex-col gap-2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-sm",
className,
)}
{...rest}
>
{children}
</div>
</ColorPickerContext.Provider>
);
});
registry/new-york/ui/fill-picker/parts/field.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Bordered, h-8 shell every multi-field input inside the picker shares.
* Visual source of truth for `<ColorPicker.ChannelInput>`, the gradient
* angle / position / radius / ellipse-radii / stop-position inputs, and
* both CSS-string inputs. Owning the shell here means a single edit
* here re-themes every text input in the package.
*/
export const FieldShell = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function FieldShell({ className, ...rest }, ref) {
return (
<div
ref={ref}
className={cn(
"flex h-8 items-stretch overflow-hidden rounded-md border border-input bg-transparent font-mono text-xs shadow-xs",
"focus-within:ring-1 focus-within:ring-ring",
className,
)}
{...rest}
/>
);
});
/** Vertical 1px divider between fields inside a `FieldShell`. */
export function FieldDivider() {
return <div aria-hidden className="w-px self-stretch bg-border" />;
}
/**
* Borderless input that lives inside a `FieldShell`. Defaults match the
* channel-input numeric field: full width of its slot, right-aligned
* tabular digits, transparent background. Pass `className` to override
* per use (e.g. left-align for hex / CSS strings).
*
* When `nudge` is set, ↑/↓ step the numeric value by `nudge` (Shift × 10).
* Implemented by dispatching a native input event so the consumer's
* existing `onChange` handler runs unchanged — no parallel commit path,
* no double validation. Non-numeric values are ignored.
*/
export interface FieldInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/**
* Step amount for ↑/↓ keyboard nudge. Shift multiplies by 10. Omit
* to disable nudge (text fields and non-numeric inputs).
*/
nudge?: number;
}
export const FieldInput = React.forwardRef<HTMLInputElement, FieldInputProps>(
function FieldInput({ className, type = "text", nudge, onKeyDown, ...rest }, ref) {
return (
<input
ref={ref}
type={type}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
onKeyDown={(e) => {
if (nudge && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
// Treat empty / unparseable input (e.g. RadiusInput in its
// "auto" placeholder state) as 0 so the first arrow press
// commits a real numeric value instead of doing nothing.
const parsed = parseFloat(e.currentTarget.value);
const cur = Number.isFinite(parsed) ? parsed : 0;
const delta =
(e.key === "ArrowUp" ? 1 : -1) * (e.shiftKey ? nudge * 10 : nudge);
const next = cur + delta;
// Use the native value setter so React's synthetic onChange
// fires — directly assigning `.value` is swallowed by React's
// controlled-input tracker.
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(e.currentTarget, String(next));
e.currentTarget.dispatchEvent(new Event("input", { bubbles: true }));
}
onKeyDown?.(e);
}}
className={cn(
"w-full min-w-0 bg-transparent px-1.5 text-right outline-none tabular-nums",
className,
)}
{...rest}
/>
);
},
);
export interface FieldDraftInputProps
extends Omit<FieldInputProps, "value" | "defaultValue" | "onChange"> {
/** Canonical display string; re-seeds the draft whenever it changes. */
value: string;
/**
* Called with the raw draft on Enter, on blur after an edit, and on each
* ↑/↓ nudge. The consumer parses, clamps and writes; an unparseable or
* clamped commit just snaps the draft back to `value`.
*/
onCommit: (raw: string) => void;
}
/**
* `FieldInput` with a local draft: keystrokes only edit the text, and the
* value is committed on Enter / blur (Escape reverts). Committing on every
* keystroke makes multi-digit and decimal entry impossible when the parsed
* prefix gets clamped or normalized ("4" of "400" wraps, "" can't be typed).
* ↑/↓ nudges (via `nudge`) still commit immediately.
*/
export const FieldDraftInput = React.forwardRef<
HTMLInputElement,
FieldDraftInputProps
>(function FieldDraftInput(
{ value, onCommit, onBlur, onKeyDown, nudge, ...rest },
ref,
) {
const [draft, setDraft] = React.useState(value);
const [dirty, setDirty] = React.useState(false);
// Same in-render re-seed as `ChannelField` / `HexField`.
const [prevValue, setPrevValue] = React.useState(value);
if (value !== prevValue) {
setPrevValue(value);
setDraft(value);
setDirty(false);
}
const commit = (raw: string) => {
onCommit(raw);
// Snap back to the canonical string. If the commit changed it, the
// re-seed above overwrites this on the next render anyway; if it didn't
// (clamped to the same value, unparseable), this is what clears "999".
setDraft(value);
setDirty(false);
};
return (
<FieldInput
ref={ref}
nudge={nudge}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setDirty(true);
}}
onBlur={(e) => {
if (dirty) commit(e.currentTarget.value);
onBlur?.(e);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (dirty) commit(e.currentTarget.value);
} else if (e.key === "Escape") {
setDraft(value);
setDirty(false);
} else if (nudge && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
// FieldInput already wrote the nudged number into the input.
commit(e.currentTarget.value);
}
onKeyDown?.(e);
}}
{...rest}
/>
);
});
/**
* Flex slot that pairs a `FieldInput` with an optional `FieldSuffix`
* (the muted `°`, `%`, `px`, `×` glyph). Renders as a `<label>` so the
* suffix is part of the clickable hit area but doesn't steal focus.
*/
export const FieldInputGroup = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(function FieldInputGroup({ className, ...rest }, ref) {
return (
<label
ref={ref}
className={cn(
"relative inline-flex h-full min-w-0 flex-1 items-center justify-end",
className,
)}
{...rest}
/>
);
});
/** Muted, non-interactive suffix label (°, %, px, ×). */
export const FieldSuffix = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(function FieldSuffix({ className, ...rest }, ref) {
return (
<span
ref={ref}
aria-hidden
className={cn(
"pointer-events-none pr-1.5 text-muted-foreground",
className,
)}
{...rest}
/>
);
});
registry/new-york/ui/fill-picker/parts/area.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "../context";
import {
findCusp,
findMaxChroma,
formatColor,
gamutFromFormat as libGamutFromFormat,
gamutSignedDistance,
linSrgbToLinP3,
oklchToLinearSrgb,
toGamut,
} from "../lib/color";
import type { Gamut, OklchColor } from "../lib/types";
import { useLiveAnnounce } from "./use-live-announce";
import { cn } from "@/lib/utils";
export type AreaMode = "oklch-cl" | "hsv-sv" | "oklch-hc";
export type AreaGamut = Gamut | "none";
export interface AreaProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* What the 2D Area represents.
* - oklch-cl: Y = OKLCH lightness, X = chroma normalized to gamut. Perceptually uniform.
* Top row is white-ish (max chroma at L=1 is 0). Most-saturated colors live at mid-Y.
* - hsv-sv: Y = HSV-style "value", X = saturation. OKHSV-like — anchored to the gamut
* cusp so top-right corner is fully saturated (Photoshop/Framer feel).
* - oklch-hc: X = hue, Y = chroma normalized (lightness picked separately).
*/
mode?: AreaMode;
/**
* Ignored when `gamut` is "srgb" / "p3" / "rec2020" — chroma fills the
* square up to the gamut surface. Only meaningful when `gamut === "none"`,
* where it bounds the absolute-chroma X axis.
*/
chromaMax?: number;
/**
* Render gamut. The square is filled with in-gamut colors up to this
* gamut's surface, and warning lines mark narrower-gamut cutoffs inside.
* Defaults to the gamut implied by the active output format
* (hex/rgb/hsl/hsb → srgb, p3 → p3, oklch/oklab → rec2020).
* Pass "none" to disable warping and paint the raw OKLCH plane up to
* `chromaMax` (no fill warp, no lines).
*/
gamut?: AreaGamut;
/**
* Show the gamut-cutoff warning lines drawn inside the active render gamut.
* Defaults to true. Setting to false hides them regardless of `gamut` —
* useful for a quieter visual when the badge already conveys gamut status.
* No effect when `gamut` is "srgb" or "none" (nothing to draw anyway).
*/
showWarningLines?: boolean;
/** Render resolution of the gradient canvas. Higher = sharper, slower. Default 160. */
resolution?: number;
/**
* Soft-proof out-of-display colors instead of per-channel clipping them.
* When the active render gamut exceeds the display's (e.g. `gamut="rec2020"`
* on a P3 monitor), pixels past the display gamut are chroma-reduced in
* OKLCH to the display surface — preserving hue and lightness, flattening
* chroma. The default (false) preserves the legacy per-channel clip, which
* is cheaper but introduces hue shifts and posterization in unrenderable
* regions. Off by default to keep first-paint cost identical to before.
*/
softProof?: boolean;
}
/** Which gamuts get a warning line drawn inside the active render gamut. */
function warningGamuts(active: AreaGamut): Gamut[] {
switch (active) {
case "srgb":
case "none":
return [];
case "p3":
return ["srgb"];
case "rec2020":
return ["srgb", "p3"];
}
}
/**
* Read display-P3 support fresh on the client and re-react if the user moves
* the window to a different display. Module-load evaluation locks the value
* to `false` in Next.js's SSR module cache (window is undefined there), and
* `useState(() => ...)` would diverge between server and client renders.
* `useSyncExternalStore` returns `false` on the server (matching SSR) and the
* real value on the client during reconciliation — no hydration mismatch.
*/
const subscribeP3 =
typeof window !== "undefined" && typeof window.matchMedia === "function"
? (onChange: () => void) => {
const mq = window.matchMedia("(color-gamut: p3)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
: () => () => {};
const getP3Client = () =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(color-gamut: p3)").matches;
const getP3Server = () => false;
export const Area = React.forwardRef<HTMLDivElement, AreaProps>(function Area(
{
mode = "oklch-cl",
chromaMax = 0.4,
gamut: gamutProp,
showWarningLines = true,
resolution = 160,
softProof = false,
className,
...rest
},
ref,
) {
const { color, setColor, format } = useColorPickerContext();
const supportsP3 = React.useSyncExternalStore(
subscribeP3,
getP3Client,
getP3Server,
);
const gamut: AreaGamut = gamutProp ?? libGamutFromFormat(format);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [paths, setPaths] = React.useState<string[][]>([]);
// Bead position override. The (X, Y) → OKLCH mapping is lossy at the
// gamut poles: at l=0 or l=1 every X collapses to chroma 0, so deriving
// the bead back from the resulting color always recovers X=0 — making
// the bead jump to the left edge when the pointer drags past the top or
// bottom. We keep the user's last picked (xn, yn) and render the bead
// there. Cleared whenever color changes from a source other than our
// own moveTo (e.g. swatch click, controlled value, input typing).
const [pickPos, setPickPos] = React.useState<[number, number] | null>(null);
const selfSetRef = React.useRef(false);
React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);
// Adjusting state during rendering — React-blessed alternative to
// syncing color → pickPos in a useEffect. We compare channel values
// (not the OKLCH object identity, which a controlled ColorPicker may
// re-create each render even when values are unchanged) against the
// previous render's snapshot. When the user-driven path moved the
// bead, `selfSetRef` short-circuits the clear so the override sticks.
const [prevColor, setPrevColor] = React.useState(color);
if (
prevColor.l !== color.l ||
prevColor.c !== color.c ||
prevColor.h !== color.h ||
prevColor.alpha !== color.alpha
) {
setPrevColor(color);
if (!selfSetRef.current && pickPos !== null) setPickPos(null);
}
// Disarm after every commit rather than on the echo: a pick that leaves
// the color unchanged (e.g. any X at the white pole) never produces an
// echo, and a still-armed flag would swallow the next *external* change.
// Our own pick's color update lands in the same render as `setPickPos`.
React.useEffect(() => {
selfSetRef.current = false;
});
// The gradient and warning lines depend only on the axis the mode keeps
// *fixed* (hue for oklch-cl/hsv-sv, lightness for oklch-hc). Depending on
// every channel of `color` would trigger a 25 600-pixel canvas repaint plus
// a 128² marching-squares pass on every pointer tick — enough to stall the
// bead. Narrowing the dep to the locked axis keeps drags free.
const fixedAxisValue = mode === "oklch-hc" ? color.l : color.h;
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const w = resolution;
const h = resolution;
canvas.width = w;
canvas.height = h;
const ctx2dOpts: CanvasRenderingContext2DSettings = supportsP3
? { colorSpace: "display-p3" }
: {};
const ctx = canvas.getContext("2d", ctx2dOpts);
if (!ctx) return;
const img = ctx.createImageData(w, h);
paintGradient(img, w, h, mode, color, chromaMax, gamut, supportsP3, softProof);
ctx.putImageData(img, 0, 0);
if (gamut === "none" || !showWarningLines) {
setPaths([]);
} else {
setPaths(
warningGamuts(gamut).map((g) =>
computeGamutPaths(mode, color, chromaMax, g, gamut),
),
);
}
// `color` is intentionally omitted — gradient + warning lines depend only
// on the locked axis (`fixedAxisValue`), not on every channel of `color`.
// Repainting on every pointer tick would stall the bead.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, fixedAxisValue, chromaMax, gamut, showWarningLines, resolution, softProof, supportsP3]);
const [derivedPx, derivedPy] = positionFor(mode, color, chromaMax, gamut);
const [px, py] = pickPos ?? [derivedPx, derivedPy];
// role="application" carries no value semantics, so keyboard adjustments
// would otherwise be silent to screen readers. Announce the value text in a
// polite live region, debounced so held-down arrows don't flood the queue.
const [liveText, announce] = useLiveAnnounce();
const moveTo = React.useCallback(
(x: number, y: number) => {
const xn = clamp01(x);
const yn = clamp01(y);
const next = sampleAt(mode, color, chromaMax, gamut, xn, yn);
setPickPos([xn, yn]);
selfSetRef.current = true;
// With warping every (X, Y) is in-gamut by construction, so toGamut is
// defensive only — guards against drift at numerical boundaries. When
// gamut === "none" the user has explicitly opted out of warping and we
// skip clamping entirely (raw OKLCH plane).
let committed: OklchColor;
if (gamut !== "none") {
const targetHue = next.h;
const clamped = toGamut(next, gamut as Gamut);
committed = { ...clamped, h: targetHue };
} else {
committed = next;
}
setColor(committed);
announce(ariaValueTextFor(mode, committed, chromaMax, gamut));
},
[mode, chromaMax, color, setColor, gamut, announce],
);
const handlePointer = React.useCallback(
(clientX: number, clientY: number) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// A collapsed / animating-out container has no pickable surface.
if (rect.width <= 0 || rect.height <= 0) return;
moveTo((clientX - rect.left) / rect.width, (clientY - rect.top) / rect.height);
},
[moveTo],
);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
handlePointer(e.clientX, e.clientY);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.buttons !== 1) return;
handlePointer(e.clientX, e.clientY);
};
const releaseCapture = (e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget as HTMLDivElement;
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? 10 : 1;
const stepX = big / 100; // % of axis
const stepY = big / 100;
let nx = px;
let ny = py;
switch (e.key) {
case "ArrowLeft":
nx -= stepX;
break;
case "ArrowRight":
nx += stepX;
break;
case "ArrowUp":
ny -= stepY;
break;
case "ArrowDown":
ny += stepY;
break;
case "Home":
nx = 0;
break;
case "End":
nx = 1;
break;
case "PageUp":
ny -= 0.1;
break;
case "PageDown":
ny += 0.1;
break;
default:
return;
}
e.preventDefault();
moveTo(nx, ny);
};
const valueText = ariaValueTextFor(mode, color, chromaMax, gamut);
return (
<div
ref={containerRef}
data-slot="color-picker-area"
role="application"
aria-label={`Color area: ${valueText}`}
aria-roledescription="2D color area, use arrow keys to adjust"
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={releaseCapture}
onPointerCancel={releaseCapture}
onKeyDown={onKeyDown}
className={cn(
"relative h-45 w-full cursor-crosshair overflow-hidden rounded-md border border-border outline-none touch-none select-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
className,
)}
{...rest}
>
<canvas
ref={canvasRef}
className="block h-full w-full"
aria-hidden="true"
/>
{paths.length > 0 && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 1 1"
preserveAspectRatio="none"
>
{paths.map((groupPaths, gi) =>
groupPaths.map((d, i) => (
<g key={`${gi}-${i}`}>
<path
d={d}
fill="none"
stroke="rgba(0,0,0,0.55)"
strokeWidth={3}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
<path
d={d}
fill="none"
stroke="rgba(255,255,255,0.6)"
strokeWidth={1}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</g>
)),
)}
</svg>
)}
<span aria-live="polite" className="sr-only">
{liveText}
</span>
<div
className="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]"
style={{
left: `${px * 100}%`,
top: `${py * 100}%`,
background: formatColor({ ...color, alpha: 1 }, "oklch"),
}}
/>
</div>
);
});
function clamp01(x: number) {
return x < 0 ? 0 : x > 1 ? 1 : x;
}
function positionFor(
mode: AreaMode,
c: OklchColor,
chromaMax: number,
gamut: AreaGamut,
): [number, number] {
if (gamut === "none") {
switch (mode) {
case "oklch-cl":
return [clamp01(c.c / chromaMax), clamp01(1 - c.l)];
case "hsv-sv": {
const v = c.l;
const s = v > 0 ? c.c / Math.max(v * chromaMax, 1e-6) : 0;
return [clamp01(s), clamp01(1 - v)];
}
case "oklch-hc":
return [
clamp01((((c.h % 360) + 360) % 360) / 360),
clamp01(1 - c.c / chromaMax),
];
}
}
switch (mode) {
case "oklch-cl": {
const maxC = findMaxChroma(c.l, c.h, gamut as Gamut);
const safeMaxC = maxC > 1e-6 ? maxC : 1e-6;
return [clamp01(c.c / safeMaxC), clamp01(1 - c.l)];
}
case "hsv-sv": {
const cusp = findCusp(c.h, gamut as Gamut);
// L = V·(1 − S·(1 − Lc)), C = V·S·Cc → solve for V, S.
const V = clamp01(c.l + (c.c * (1 - cusp.l)) / Math.max(cusp.c, 1e-6));
const S = V > 1e-6 ? clamp01(c.c / (V * Math.max(cusp.c, 1e-6))) : 0;
return [S, clamp01(1 - V)];
}
case "oklch-hc": {
const maxC = findMaxChroma(c.l, c.h, gamut as Gamut);
const safeMaxC = maxC > 1e-6 ? maxC : 1e-6;
return [
clamp01((((c.h % 360) + 360) % 360) / 360),
clamp01(1 - c.c / safeMaxC),
];
}
}
}
/**
* Build the OKLCH color for a normalized (x, y) inside the area, given the
* locked axis on `base`. Mirror of `positionFor`.
*/
function sampleAt(
mode: AreaMode,
base: OklchColor,
chromaMax: number,
gamut: AreaGamut,
xn: number,
yn: number,
): OklchColor {
if (gamut === "none") {
switch (mode) {
case "oklch-cl":
return { ...base, c: xn * chromaMax, l: 1 - yn };
case "hsv-sv": {
const v = 1 - yn;
return { ...base, l: v, c: xn * v * chromaMax };
}
case "oklch-hc":
return { ...base, h: xn * 360, c: (1 - yn) * chromaMax };
}
}
switch (mode) {
case "oklch-cl": {
const l = 1 - yn;
const maxC = findMaxChroma(l, base.h, gamut as Gamut);
return { ...base, l, c: xn * maxC };
}
case "hsv-sv": {
const cusp = findCusp(base.h, gamut as Gamut);
const V = 1 - yn;
const S = xn;
const l = V * (1 - S * (1 - cusp.l));
const c = V * S * cusp.c;
return { ...base, l, c };
}
case "oklch-hc": {
const h = xn * 360;
const maxC = findMaxChroma(base.l, h, gamut as Gamut);
return { ...base, h, c: (1 - yn) * maxC };
}
}
}
function ariaValueTextFor(
mode: AreaMode,
c: OklchColor,
chromaMax: number,
gamut: AreaGamut,
): string {
switch (mode) {
case "oklch-cl": {
const maxC =
gamut === "none"
? chromaMax
: findMaxChroma(c.l, c.h, gamut as Gamut) || chromaMax;
return `Lightness ${(c.l * 100).toFixed(0)} percent, chroma ${c.c.toFixed(2)} of ${maxC.toFixed(2)}, hue ${c.h.toFixed(0)} degrees`;
}
case "hsv-sv": {
if (gamut === "none") {
const v = c.l;
const s = (c.c / Math.max(v * chromaMax, 1e-6)) * 100 || 0;
return `Saturation ${s.toFixed(0)} percent, value ${(v * 100).toFixed(0)} percent, hue ${c.h.toFixed(0)} degrees`;
}
const cusp = findCusp(c.h, gamut as Gamut);
const V = clamp01(c.l + (c.c * (1 - cusp.l)) / Math.max(cusp.c, 1e-6));
const S = V > 1e-6 ? clamp01(c.c / (V * Math.max(cusp.c, 1e-6))) : 0;
return `Saturation ${(S * 100).toFixed(0)} percent, value ${(V * 100).toFixed(0)} percent, hue ${c.h.toFixed(0)} degrees`;
}
case "oklch-hc": {
const maxC =
gamut === "none"
? chromaMax
: findMaxChroma(c.l, c.h, gamut as Gamut) || chromaMax;
return `Hue ${c.h.toFixed(0)} degrees, chroma ${c.c.toFixed(2)} of ${maxC.toFixed(2)}, lightness ${(c.l * 100).toFixed(0)} percent`;
}
}
}
/* ----------------------- gradient painting ----------------------- */
/* Per-paint LUT of max chroma for the moving axis. Each row (or column for
* oklch-hc) gets one bisection so the per-pixel cost stays a single multiply.
* When gamut === "none" we skip the LUT and paint absolute chroma up to
* chromaMax (the legacy unwrapped plane). */
function paintGradient(
img: ImageData,
w: number,
h: number,
mode: AreaMode,
base: OklchColor,
chromaMax: number,
gamut: AreaGamut,
canvasIsP3: boolean,
softProof: boolean,
) {
const data = img.data;
const ctx = buildWarpContext(mode, base, gamut, w, h);
// Soft-proof: build a parallel max-chroma context keyed to the *display*
// gamut. Per pixel we'll clamp chroma down to this surface so out-of-display
// OKLCH samples render as their hue/lightness-faithful gamut-mapped twin
// instead of a per-channel-clipped fake. Skipping the displayCtx entirely
// when render gamut already fits inside the display avoids a useless second
// LUT. "none" gamut opts out of warping in general, so soft-proof is a no-op.
const displayCap: AreaGamut =
gamut === "none"
? "none"
: canvasIsP3
? "p3"
: "srgb";
const needsSoftProof =
softProof && gamut !== "none" && isWiderThan(gamut, displayCap);
const displayCtx: WarpContext | null = needsSoftProof
? buildWarpContext(mode, base, displayCap, w, h)
: null;
for (let yPx = 0; yPx < h; yPx++) {
const yn = yPx / (h - 1);
for (let xPx = 0; xPx < w; xPx++) {
const xn = xPx / (w - 1);
const [l, c, hue] = warpedSample(ctx, mode, base, chromaMax, xn, yn, xPx, yPx);
const cClamped = displayCtx
? Math.min(c, displayMaxChroma(displayCtx, mode, l, xPx, yPx))
: c;
const lin = oklchToLinearSrgb(l, cClamped, hue);
const targetLin = canvasIsP3 ? linSrgbToLinP3(lin) : lin;
const idx = (yPx * w + xPx) * 4;
data[idx] = clampByte(srgbEncode(targetLin.r) * 255);
data[idx + 1] = clampByte(srgbEncode(targetLin.g) * 255);
data[idx + 2] = clampByte(srgbEncode(targetLin.b) * 255);
data[idx + 3] = 255;
}
}
}
function isWiderThan(a: AreaGamut, b: AreaGamut): boolean {
const rank: Record<AreaGamut, number> = { none: 99, rec2020: 3, p3: 2, srgb: 1 };
return rank[a] > rank[b];
}
/**
* Read the display-gamut max chroma at this pixel using the soft-proof warp
* context. For oklch-cl/oklch-hc the precomputed 1D LUT is row-/column-keyed.
* For hsv-sv we evaluate the OKHSV-ish triangle around the locked-hue cusp:
* chroma rises linearly from 0 at l=0 to cusp.c at l=cusp.l, then falls back
* to 0 at l=1. That matches how `warpedSample` already builds hsv-sv samples,
* so the clamp is consistent with the warp.
*/
function displayMaxChroma(
ctx: WarpContext,
mode: AreaMode,
l: number,
xPx: number,
yPx: number,
): number {
switch (ctx.kind) {
case "none":
return Number.POSITIVE_INFINITY;
case "lut-y":
return ctx.lut[yPx];
case "lut-x":
return ctx.lut[xPx];
case "cusp": {
if (mode !== "hsv-sv") return Number.POSITIVE_INFINITY;
const { l: cl, c: cc } = ctx.cusp;
if (l <= cl) return cc * (l / Math.max(cl, 1e-6));
return cc * ((1 - l) / Math.max(1 - cl, 1e-6));
}
}
}
/**
* Per-paint warp resources. For oklch-cl and oklch-hc we precompute a 1D
* max-chroma LUT keyed by the moving axis. For hsv-sv we precompute the
* (locked-hue) cusp once. For "none" we hold no resources — the legacy raw
* mapping uses chromaMax directly.
*/
type WarpContext =
| { kind: "none" }
| { kind: "lut-y"; lut: Float32Array }
| { kind: "lut-x"; lut: Float32Array }
| { kind: "cusp"; cusp: { l: number; c: number } };
function buildWarpContext(
mode: AreaMode,
base: OklchColor,
gamut: AreaGamut,
w: number,
h: number,
): WarpContext {
if (gamut === "none") return { kind: "none" };
switch (mode) {
case "oklch-cl": {
const lut = new Float32Array(h);
for (let j = 0; j < h; j++) {
const l = 1 - j / (h - 1);
lut[j] = findMaxChroma(l, base.h, gamut as Gamut);
}
return { kind: "lut-y", lut };
}
case "oklch-hc": {
const lut = new Float32Array(w);
for (let i = 0; i < w; i++) {
const hue = (i / (w - 1)) * 360;
lut[i] = findMaxChroma(base.l, hue, gamut as Gamut);
}
return { kind: "lut-x", lut };
}
case "hsv-sv":
return { kind: "cusp", cusp: findCusp(base.h, gamut as Gamut) };
}
}
/**
* Read (l, c, hue) for a normalized (xn, yn) using the precomputed warp
* context. `xPx`/`yPx` index into the LUT for oklch-cl/oklch-hc; hsv-sv and
* "none" don't use them.
*/
function warpedSample(
ctx: WarpContext,
mode: AreaMode,
base: OklchColor,
chromaMax: number,
xn: number,
yn: number,
xPx: number,
yPx: number,
): [number, number, number] {
if (ctx.kind === "none") {
const ok = sampleRaw(mode, base, chromaMax, xn, yn);
return [ok.l, ok.c, ok.h];
}
switch (mode) {
case "oklch-cl": {
const l = 1 - yn;
const maxC = (ctx as { kind: "lut-y"; lut: Float32Array }).lut[yPx];
return [l, xn * maxC, base.h];
}
case "hsv-sv": {
const cusp = (ctx as { kind: "cusp"; cusp: { l: number; c: number } }).cusp;
const V = 1 - yn;
const S = xn;
const l = V * (1 - S * (1 - cusp.l));
const c = V * S * cusp.c;
return [l, c, base.h];
}
case "oklch-hc": {
const hue = xn * 360;
const maxC = (ctx as { kind: "lut-x"; lut: Float32Array }).lut[xPx];
return [base.l, (1 - yn) * maxC, hue];
}
}
}
function sampleRaw(
mode: AreaMode,
base: OklchColor,
chromaMax: number,
xn: number,
yn: number,
): { l: number; c: number; h: number } {
switch (mode) {
case "oklch-cl":
return { l: 1 - yn, c: xn * chromaMax, h: base.h };
case "hsv-sv": {
const v = 1 - yn;
return { l: v, c: xn * v * chromaMax, h: base.h };
}
case "oklch-hc":
return { l: base.l, c: (1 - yn) * chromaMax, h: xn * 360 };
}
}
/**
* Compute a warning gamut's boundary inside the *active render gamut's* warped
* coordinate space, as SVG path strings in normalized 0..1 coords. Marching
* squares on a 128² grid where each cell samples gamutSignedDistance against
* the warning gamut, with sample positions warped through the active gamut's
* max-chroma so the line lands on the correct (X, Y) inside the filled square.
*/
function computeGamutPaths(
mode: AreaMode,
base: OklchColor,
chromaMax: number,
warningGamut: Gamut,
activeGamut: AreaGamut,
): string[] {
const N = 128;
const stride = N + 1;
const sd = new Float32Array(stride * stride);
// Precompute hsv-sv's cusp once; without this the 16 384 grid samples would
// each rebuild it (52 findMaxChroma calls each → ~850 K total).
const cusp =
mode === "hsv-sv" && activeGamut !== "none"
? findCusp(base.h, activeGamut as Gamut)
: null;
for (let j = 0; j <= N; j++) {
const yn = j / N;
for (let i = 0; i <= N; i++) {
const xn = i / N;
const ok =
activeGamut === "none"
? sampleRaw(mode, base, chromaMax, xn, yn)
: sampleWarpedForLine(mode, base, activeGamut as Gamut, xn, yn, cusp);
sd[j * stride + i] = gamutSignedDistance(
{ l: ok.l, c: ok.c, h: ok.h, alpha: 1 },
warningGamut,
);
}
}
const interp = (
ax: number, ay: number, av: number,
bx: number, by: number, bv: number,
): [number, number] => {
const t = av / (av - bv);
return [ax + (bx - ax) * t, ay + (by - ay) * t];
};
const segs: number[] = []; // flat: [ax, ay, bx, by, ax, ay, bx, by, ...]
const pushSeg = (a: [number, number], b: [number, number]) => {
if (a[0] === b[0] && a[1] === b[1]) return;
segs.push(a[0], a[1], b[0], b[1]);
};
for (let j = 0; j < N; j++) {
for (let i = 0; i < N; i++) {
const aTL = sd[j * stride + i];
const aTR = sd[j * stride + i + 1];
const aBL = sd[(j + 1) * stride + i];
const aBR = sd[(j + 1) * stride + i + 1];
const code =
(aTL > 0 ? 8 : 0) |
(aTR > 0 ? 4 : 0) |
(aBR > 0 ? 2 : 0) |
(aBL > 0 ? 1 : 0);
if (code === 0 || code === 15) continue;
const x0 = i / N;
const y0 = j / N;
const x1 = (i + 1) / N;
const y1 = (j + 1) / N;
const top = () => interp(x0, y0, aTL, x1, y0, aTR);
const right = () => interp(x1, y0, aTR, x1, y1, aBR);
const bottom = () => interp(x0, y1, aBL, x1, y1, aBR);
const left = () => interp(x0, y0, aTL, x0, y1, aBL);
switch (code) {
case 1: case 14: pushSeg(left(), bottom()); break;
case 2: case 13: pushSeg(bottom(), right()); break;
case 3: case 12: pushSeg(left(), right()); break;
case 4: case 11: pushSeg(top(), right()); break;
case 6: case 9: pushSeg(top(), bottom()); break;
case 7: case 8: pushSeg(left(), top()); break;
case 5: pushSeg(left(), top()); pushSeg(bottom(), right()); break;
case 10: pushSeg(left(), bottom()); pushSeg(top(), right()); break;
}
}
}
const segCount = segs.length / 4;
if (segCount === 0) return [];
// Build endpoint map keyed by quantized coords. Endpoints from adjacent cells
// share the same `interp` inputs along their shared edge, so they collide
// exactly — quantization only protects against float ULP drift.
const Q = 1_000_000;
const key = (x: number, y: number) => `${(x * Q) | 0},${(y * Q) | 0}`;
type Endpoint = { seg: number; end: 0 | 1 };
const endpointMap = new Map<string, Endpoint[]>();
const addEp = (k: string, e: Endpoint) => {
const arr = endpointMap.get(k);
if (arr) arr.push(e);
else endpointMap.set(k, [e]);
};
for (let s = 0; s < segCount; s++) {
const o = s * 4;
addEp(key(segs[o], segs[o + 1]), { seg: s, end: 0 });
addEp(key(segs[o + 2], segs[o + 3]), { seg: s, end: 1 });
}
const used = new Uint8Array(segCount);
const popEndpointAt = (k: string): Endpoint | null => {
const arr = endpointMap.get(k);
if (!arr) return null;
while (arr.length > 0) {
const e = arr.pop()!;
if (!used[e.seg]) return e;
}
return null;
};
const polylines: number[][] = [];
for (let start = 0; start < segCount; start++) {
if (used[start]) continue;
used[start] = 1;
const o = start * 4;
const pts: number[] = [segs[o], segs[o + 1], segs[o + 2], segs[o + 3]];
// Extend forward off the tail.
while (true) {
const tx = pts[pts.length - 2];
const ty = pts[pts.length - 1];
const e = popEndpointAt(key(tx, ty));
if (!e) break;
used[e.seg] = 1;
const no = e.seg * 4;
if (e.end === 0) pts.push(segs[no + 2], segs[no + 3]);
else pts.push(segs[no], segs[no + 1]);
}
// Extend backward off the head.
while (true) {
const hx = pts[0];
const hy = pts[1];
const e = popEndpointAt(key(hx, hy));
if (!e) break;
used[e.seg] = 1;
const no = e.seg * 4;
if (e.end === 0) pts.unshift(segs[no + 2], segs[no + 3]);
else pts.unshift(segs[no], segs[no + 1]);
}
polylines.push(pts);
}
const fmt = (v: number) => v.toFixed(5);
return polylines.map((pts) => {
const last = pts.length;
const closed =
last >= 4 && pts[0] === pts[last - 2] && pts[1] === pts[last - 1];
let d = `M${fmt(pts[0])},${fmt(pts[1])}`;
for (let i = 2; i < last; i += 2) {
d += `L${fmt(pts[i])},${fmt(pts[i + 1])}`;
}
if (closed) d += "Z";
return d;
});
}
/**
* Sample helper for the warning-line marching squares. Mirrors `sampleAt`
* but takes raw inputs (no `gamut === "none"` branch — callers handle it).
* `cusp` is required for hsv-sv mode; precomputed by the caller for speed.
*/
function sampleWarpedForLine(
mode: AreaMode,
base: OklchColor,
activeGamut: Gamut,
xn: number,
yn: number,
cusp: { l: number; c: number } | null,
): { l: number; c: number; h: number } {
switch (mode) {
case "oklch-cl": {
const l = 1 - yn;
const maxC = findMaxChroma(l, base.h, activeGamut);
return { l, c: xn * maxC, h: base.h };
}
case "hsv-sv": {
const k = cusp ?? findCusp(base.h, activeGamut);
const V = 1 - yn;
const S = xn;
const l = V * (1 - S * (1 - k.l));
const c = V * S * k.c;
return { l, c, h: base.h };
}
case "oklch-hc": {
const h = xn * 360;
const maxC = findMaxChroma(base.l, h, activeGamut);
return { l: base.l, c: (1 - yn) * maxC, h };
}
}
}
function srgbEncode(v: number) {
const x = v < 0 ? 0 : v > 1 ? 1 : v;
return x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
}
function clampByte(x: number) {
return x < 0 ? 0 : x > 255 ? 255 : Math.round(x);
}
registry/new-york/ui/fill-picker/parts/chroma.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "../context";
import { formatColor } from "../lib/color";
import { cn } from "@/lib/utils";
/**
* OKLCH chroma is unbounded in principle but practically maxes out
* around ~0.4 for the wide hues; gamut-clamping handles anything
* higher. Matches the slider/typed range in `lib/channels.ts`.
*/
const CHROMA_MAX = 0.4;
export interface ChromaProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onKeyDown"> {
orientation?: "horizontal" | "vertical";
}
export const Chroma = React.forwardRef<HTMLDivElement, ChromaProps>(
function Chroma({ orientation = "horizontal", className, ...rest }, ref) {
const { color, setComponent } = useColorPickerContext();
const trackRef = React.useRef<HTMLDivElement | null>(null);
React.useImperativeHandle(ref, () => trackRef.current as HTMLDivElement);
const moveTo = (clientCoord: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const ratio =
orientation === "horizontal"
? (clientCoord - rect.left) / rect.width
: // Vertical is bottom-anchored (min at the bottom), matching the
// Base UI variant and ArrowUp = increase.
1 - (clientCoord - rect.top) / rect.height;
const clamped = Math.max(0, Math.min(1, ratio));
setComponent("c", clamped * CHROMA_MAX);
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
moveTo(orientation === "horizontal" ? e.clientX : e.clientY);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.buttons !== 1) return;
moveTo(orientation === "horizontal" ? e.clientX : e.clientY);
};
const releaseCapture = (e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget as HTMLDivElement;
if (el.hasPointerCapture(e.pointerId))
el.releasePointerCapture(e.pointerId);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? 0.05 : 0.005;
let next = color.c;
switch (e.key) {
case "ArrowLeft":
case "ArrowDown":
next -= big;
break;
case "ArrowRight":
case "ArrowUp":
next += big;
break;
case "Home":
next = 0;
break;
case "End":
next = CHROMA_MAX;
break;
case "PageUp":
next += 0.05;
break;
case "PageDown":
next -= 0.05;
break;
default:
return;
}
e.preventDefault();
setComponent("c", Math.max(0, Math.min(CHROMA_MAX, next)));
};
const isVertical = orientation === "vertical";
// Build the gradient at the current hue & lightness so the ramp shows
// how chroma changes _their_ color, not a generic gray→vivid ramp.
const stops = React.useMemo(() => {
const samples = 8;
const arr: string[] = [];
for (let i = 0; i <= samples; i++) {
const c = (i / samples) * CHROMA_MAX;
arr.push(formatColor({ l: color.l, c, h: color.h, alpha: 1 }, "oklch"));
}
return arr.join(", ");
}, [color.h, color.l]);
const ratio = Math.max(0, Math.min(1, color.c / CHROMA_MAX));
return (
<div
ref={trackRef}
data-slot="color-picker-chroma"
role="slider"
aria-label="Chroma"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ratio * 100)}
aria-valuetext={`${color.c.toFixed(3)}`}
aria-orientation={orientation}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={releaseCapture}
onPointerCancel={releaseCapture}
onKeyDown={onKeyDown}
className={cn(
"relative cursor-pointer rounded-full outline-none touch-none",
isVertical ? "h-32 w-3" : "h-3 w-full",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
className,
)}
style={{
background: isVertical
? `linear-gradient(to top, ${stops})`
: `linear-gradient(to right, ${stops})`,
}}
{...rest}
>
<div
className="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]"
style={
isVertical
? {
left: "50%",
top: `calc((1 - ${ratio}) * (100% - 16px) + 8px)`,
background: formatColor({ ...color, alpha: 1 }, "oklch"),
}
: {
left: `calc(${ratio} * (100% - 16px) + 8px)`,
top: "50%",
background: formatColor({ ...color, alpha: 1 }, "oklch"),
}
}
/>
</div>
);
},
);
registry/new-york/ui/fill-picker/parts/css-input.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "../context";
import { cn } from "@/lib/utils";
import { FieldInput, FieldShell } from "./field";
export interface CssInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange"
> {}
export const CssInput = React.forwardRef<HTMLInputElement, CssInputProps>(
function CssInput({ className, ...rest }, ref) {
const { formatted, setFromString } = useColorPickerContext();
// Sync draft when canonical value changes externally (slider drags etc.)
// via in-render adjustment — no useEffect needed.
const [draft, setDraft] = React.useState(formatted);
const [prevFormatted, setPrevFormatted] = React.useState(formatted);
const [error, setError] = React.useState(false);
if (formatted !== prevFormatted) {
setPrevFormatted(formatted);
setDraft(formatted);
setError(false);
}
const commit = (value: string) => {
const ok = setFromString(value.trim());
setError(!ok);
};
return (
<FieldShell
data-slot="color-picker-input"
className={cn("w-full", className)}
>
<FieldInput
ref={ref}
value={draft}
aria-invalid={error || undefined}
aria-label="Color value"
onChange={(e) => {
setDraft(e.target.value);
setError(false);
}}
onBlur={(e) => commit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit(e.currentTarget.value);
} else if (e.key === "Escape") {
setDraft(formatted);
setError(false);
}
}}
className={cn(
"flex-1 px-2 text-left",
error && "text-destructive",
)}
{...rest}
/>
</FieldShell>
);
},
);
registry/new-york/ui/fill-picker/parts/preview.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "../context";
import { formatColor } from "../lib/color";
import { cn } from "@/lib/utils";
import { CHECKERBOARD_LG, SAMPLE_EDGE } from "../lib/constants";
export interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {}
export const Preview = React.forwardRef<HTMLDivElement, PreviewProps>(function Preview(
{ className, ...rest },
ref,
) {
const { color, background } = useColorPickerContext();
const fg = formatColor(color, "p3");
const bg = formatColor(background, "p3");
return (
<div
ref={ref}
data-slot="color-picker-preview"
role="img"
aria-label="Color preview over background"
className={cn(
"relative size-10 shrink-0 overflow-hidden rounded-md",
SAMPLE_EDGE,
className,
)}
style={{
backgroundImage: CHECKERBOARD_LG,
backgroundSize: "12px 12px",
}}
{...rest}
>
<div
aria-hidden="true"
className="absolute inset-0"
style={{ background: bg }}
/>
<div
aria-hidden="true"
className="absolute inset-0"
style={{ background: fg }}
/>
</div>
);
});
registry/new-york/ui/fill-picker/parts/eye-dropper.tsx
"use client";
import * as React from "react";
import { Pipette } from "lucide-react";
import { useColorPickerContext } from "../context";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface EyeDropperLike {
open: (opts?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
}
declare global {
interface Window {
EyeDropper?: { new (): EyeDropperLike };
}
}
const subscribeNoop = () => () => {};
const getEyeDropperSupportClient = () =>
typeof window !== "undefined" && typeof window.EyeDropper === "function";
const getEyeDropperSupportServer = () => false;
export interface EyeDropperProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const EyeDropper = React.forwardRef<HTMLButtonElement, EyeDropperProps>(function EyeDropper(
{ className, ...rest },
ref,
) {
const { setColor } = useColorPickerContext();
// Client-only feature detection without a hydration mismatch and without the
// two-paint flash that an `useEffect(setState)` pattern introduces. The
// server snapshot returns `false` (matching SSR's empty render); the client
// snapshot reads the real value during hydration's reconciliation pass.
const supported = React.useSyncExternalStore(
subscribeNoop,
getEyeDropperSupportClient,
getEyeDropperSupportServer,
);
const [active, setActive] = React.useState(false);
if (!supported) return null;
const onClick = async () => {
setActive(true);
try {
const ed = new window.EyeDropper!();
const result = await ed.open();
if (result?.sRGBHex) setColor(result.sRGBHex);
} catch {
// user cancelled
} finally {
setActive(false);
}
};
return (
<Button
ref={ref}
data-slot="color-picker-eye-dropper"
data-state={active ? "on" : "off"}
type="button"
variant="outline"
size="icon-sm"
aria-label="Pick color from screen"
aria-pressed={active}
onClick={onClick}
className={cn(
"cursor-pointer",
active && "border-foreground/70 bg-foreground/10 text-foreground hover:bg-foreground/15",
className,
)}
{...rest}
>
<Pipette className="size-4" />
</Button>
);
});
registry/new-york/ui/fill-picker/parts/gamut-badge-shared.ts
// Tooltip-agnostic internals of GamutBadge, shared by the Radix shell
// (parts/gamut-badge.tsx) and the Base UI shell
// (fill-picker-base/parts/gamut-badge.tsx).
import type * as React from "react";
import type { GamutInfo } from "../lib/types";
export interface GamutBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
/** Show the "Gamut" prefix label. Default true. */
showLabel?: boolean;
}
export const GAMUT_BADGE_CLASS =
"inline-flex w-full cursor-default items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5 text-xs";
export function gamutLabel(
gamut: Pick<GamutInfo, "inSrgb" | "inP3" | "inRec2020">,
): string {
if (!gamut.inSrgb && gamut.inP3) return "P3";
if (!gamut.inP3 && gamut.inRec2020) return "Rec.2020";
if (!gamut.inRec2020) return "Out of gamut";
return "sRGB";
}
registry/new-york/ui/fill-picker/parts/contrast-readout-shared.tsx
"use client";
// Tooltip-agnostic internals of ContrastReadout, shared by the Radix shell
// (parts/contrast-readout.tsx) and the Base UI shell
// (fill-picker-base/parts/contrast-readout.tsx). Radix and Base UI spell
// tooltip composition incompatibly (asChild vs render, delayDuration vs
// delay), so each variant owns only that wiring; everything else lives here.
import * as React from "react";
import { Check, X } from "lucide-react";
import { useColorPickerContext } from "../context";
import { formatColor } from "../lib/color";
import { cn } from "@/lib/utils";
import { CHECKERBOARD_SM } from "../lib/constants";
export type ContrastMetric = "wcag" | "apca";
export interface ContrastReadoutProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Which contrast metrics are available. The first entry is shown by default;
* if more than one is provided, the readout becomes a button and the user
* clicks it to cycle to the next metric. Defaults to ["wcag"].
*/
metrics?: ContrastMetric[];
/** Override the initial metric. Must be present in `metrics`. */
defaultMetric?: ContrastMetric;
/** Show the metric label ("WCAG" / "APCA"). Default true. */
showLabel?: boolean;
/** Show the numeric value (ratio / Lc). Default true. */
showValue?: boolean;
/** Show the pass/fail level badges (AA, AAA, body, headline, fail). Default true. */
showBadges?: boolean;
}
const DEFAULT_METRICS: ContrastMetric[] = ["wcag"];
export interface PassRow {
ok: boolean;
label: string;
detail: string;
}
export const CONTRAST_READOUT_CLASS =
"flex w-full items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5 text-xs";
export function useContrastReadout({
metrics = DEFAULT_METRICS,
defaultMetric,
showLabel = true,
showValue = true,
showBadges = true,
}: Pick<
ContrastReadoutProps,
"metrics" | "defaultMetric" | "showLabel" | "showValue" | "showBadges"
>) {
// Empty metrics would leave activeMetric undefined and silently render
// the APCA branch — treat [] as "not provided".
if (metrics.length === 0) metrics = DEFAULT_METRICS;
const { contrast, color, background } = useColorPickerContext();
const fgCss = formatColor(color, "p3");
const bgCss = formatColor(background, "p3");
const initial =
defaultMetric && metrics.includes(defaultMetric) ? defaultMetric : metrics[0];
const [active, setActive] = React.useState<ContrastMetric>(initial);
// If the parent narrows `metrics` so the stored choice is no longer
// offered, fall back to the first option. Derived, not synced with
// setState-during-render (which is NOT visible to the rest of the same
// render pass): the stale stored value simply stops being used, and the
// user's choice resurrects if the parent restores that metric.
const activeMetric = metrics.includes(active) ? active : metrics[0];
const togglable = metrics.length > 1;
// Spoken summary of a metric — the ratio and pass/fail must be part of the
// accessible name, not just the visible text the aria-label would
// otherwise override.
const describeMetric = (metric: ContrastMetric) =>
metric === "wcag"
? `WCAG ${contrast.wcag.toFixed(2)} to 1, AA ${
contrast.wcagLevel.aaNormal ? "pass" : "fail"
}, AAA ${contrast.wcagLevel.aaaNormal ? "pass" : "fail"}`
: `APCA Lc ${contrast.apca.toFixed(1)}, ${apcaLevel(contrast.apca)}`;
const summary = describeMetric(activeMetric);
// Announced only when the user cycles metrics — announcing every color
// change would flood the SR queue during drags.
const [cycleAnnouncement, setCycleAnnouncement] = React.useState("");
const cycle = () => {
const i = metrics.indexOf(activeMetric);
const next = metrics[(i + 1) % metrics.length];
setActive(next);
setCycleAnnouncement(describeMetric(next));
};
const nextMetric = metrics[(metrics.indexOf(activeMetric) + 1) % metrics.length];
const body =
activeMetric === "wcag" ? (
<WcagBody
wcag={contrast.wcag}
aa={contrast.wcagLevel.aaNormal}
aaa={contrast.wcagLevel.aaaNormal}
showLabel={showLabel}
showValue={showValue}
showBadges={showBadges}
/>
) : (
<ApcaBody
lc={contrast.apca}
showLabel={showLabel}
showValue={showValue}
showBadges={showBadges}
/>
);
const popover =
activeMetric === "wcag"
? wcagPopover(
contrast.wcag,
contrast.wcagLevel.aaNormal,
contrast.wcagLevel.aaaNormal,
)
: apcaPopover(contrast.apca);
return {
active: activeMetric,
togglable,
nextMetric,
summary,
cycle,
cycleAnnouncement,
body,
popover,
fgCss,
bgCss,
};
}
function apcaLevel(lc: number): "headline" | "body" | "fail" {
const abs = Math.abs(lc);
return abs >= 75 ? "headline" : abs >= 60 ? "body" : "fail";
}
export function WcagBody({
wcag,
aa,
aaa,
showLabel,
showValue,
showBadges,
}: {
wcag: number;
aa: boolean;
aaa: boolean;
showLabel: boolean;
showValue: boolean;
showBadges: boolean;
}) {
return (
<>
{(showLabel || showValue) && (
<div className="flex items-center gap-1.5">
{showLabel && <span className="text-muted-foreground">WCAG</span>}
{showValue && (
<span className="font-mono font-medium">{wcag.toFixed(2)}:1</span>
)}
</div>
)}
{showBadges && (
<div className="flex items-center gap-1">
<Badge ok={aa}>AA</Badge>
<Badge ok={aaa}>AAA</Badge>
</div>
)}
</>
);
}
export function ApcaBody({
lc,
showLabel,
showValue,
showBadges,
}: {
lc: number;
showLabel: boolean;
showValue: boolean;
showBadges: boolean;
}) {
const level = apcaLevel(lc);
return (
<>
{(showLabel || showValue) && (
<div className="flex items-center gap-1.5">
{showLabel && <span className="text-muted-foreground">APCA</span>}
{showValue && (
<span className="font-mono font-medium">Lc {lc.toFixed(1)}</span>
)}
</div>
)}
{showBadges && (
<div className="flex items-center gap-1">
<Badge ok={level !== "fail"}>
{level === "headline" ? "headline" : level === "body" ? "body" : "fail"}
</Badge>
</div>
)}
</>
);
}
function Badge({ ok, children }: { ok: boolean; children: React.ReactNode }) {
return (
<span
aria-label={typeof children === "string" ? `${children} ${ok ? "passes" : "fails"}` : undefined}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider",
ok
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
: "bg-red-500/15 text-red-700 dark:text-red-400",
)}
>
{children}
</span>
);
}
export function ContrastPopoverPanel({
title,
rows,
fg,
bg,
footer,
}: {
title: string;
rows: PassRow[];
fg: string;
bg: string;
footer?: string;
}) {
return (
<div className="flex flex-col gap-2 text-left">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</div>
<div
aria-hidden
className="flex shrink-0 overflow-hidden rounded border border-border"
title={`fg ${fg} on bg ${bg}`}
>
<Chip color={fg} />
<Chip color={bg} />
</div>
</div>
<ul className="flex flex-col gap-1.5">
{rows.map((r) => (
<li key={r.label} className="flex items-start gap-2">
<span
aria-hidden
className={cn(
"mt-0.5 inline-flex size-3.5 shrink-0 items-center justify-center rounded-full",
r.ok
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/20 text-red-600 dark:text-red-400",
)}
>
{r.ok ? <Check className="size-2.5" /> : <X className="size-2.5" />}
</span>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium leading-tight">{r.label}</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{r.detail}
</span>
</div>
</li>
))}
</ul>
{footer && (
<div className="border-t border-border pt-1.5 text-[11px] text-muted-foreground">
{footer}
</div>
)}
</div>
);
}
function Chip({ color }: { color: string }) {
return (
<span
className="block size-4"
style={{ backgroundImage: CHECKERBOARD_SM, backgroundSize: "8px 8px" }}
>
<span className="block size-full" style={{ background: color }} />
</span>
);
}
export function wcagPopover(
ratio: number,
aa: boolean,
aaa: boolean,
): { title: string; rows: PassRow[] } {
return {
title: `WCAG ${ratio.toFixed(2)}:1`,
rows: [
{
ok: aa,
label: aa ? "Passes AA" : "Fails AA",
detail: "Body text needs ≥ 4.5:1",
},
{
ok: aaa,
label: aaa ? "Passes AAA" : "Fails AAA",
detail: "Enhanced body text needs ≥ 7:1",
},
],
};
}
export function apcaPopover(lc: number): { title: string; rows: PassRow[] } {
const abs = Math.abs(lc);
const passesBody = abs >= 60;
const passesHeadline = abs >= 75;
return {
title: `APCA Lc ${lc.toFixed(1)}`,
rows: [
{
ok: passesBody,
label: passesBody ? "Passes body text" : "Fails body text",
detail: "Body text needs |Lc| ≥ 60",
},
{
ok: passesHeadline,
label: passesHeadline ? "Passes headlines" : "Fails headlines",
detail: "Headline / large text needs |Lc| ≥ 75",
},
],
};
}
registry/new-york/ui/fill-picker/parts/use-live-announce.ts
"use client";
import * as React from "react";
/**
* Debounced screen-reader announcements for 2D controls (`role="application"`
* pads/handles) that have no native value semantics. Render `liveText` inside
* a visually hidden `aria-live="polite"` element and call `announce` after
* each keyboard-driven change; the debounce keeps held-down arrow keys from
* flooding the SR queue.
*/
export function useLiveAnnounce(
delayMs = 150,
): [string, (text: string) => void] {
const [liveText, setLiveText] = React.useState("");
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = React.useCallback(
(text: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setLiveText(text), delayMs);
},
[delayMs],
);
React.useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[],
);
return [liveText, announce];
}