amplo
uicolor-picker-radixsource: ok · refreshed
Amplo Color Picker (Radix)
Radix/shadcn-classic shells for the Amplo color picker — Hue, Lightness, Alpha, FormatSwitcher, ChannelInput, Swatches, GamutBadge and ContrastReadout built on shadcn's Select and Tooltip. The OKLCH engine, contexts and pointer-driven parts come from the shared color engine, pulled in as a registry dependency.
npx shadcn add "https://amplo.ale.design/r/color-picker-radix.json"free
Interaction
tooltipdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
utilsselecttooltiphttps://amplo.ale.design/r/color-picker-engine.json
Files10 · source cached 10/8/2026, 8:50:25 AM
registry/new-york/ui/fill-picker/parts/hue.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "../context";
import { hslHue, hsbHue } from "../lib/color";
import { setHueFromSlider } from "../lib/channels";
import { cn } from "@/lib/utils";
// Slider-sourced hue never reaches 360: `setHueFromSlider` wraps 360 → 0, so
// End / a drag past the right edge would snap the thumb back to the start
// (End == Home). Clamping to just below the seam keeps the thumb at the end
// and the rounded aria value at 360.
const HUE_SLIDER_MAX = 359.99;
export interface HueProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onKeyDown"> {
orientation?: "horizontal" | "vertical";
}
export const Hue = React.forwardRef<HTMLDivElement, HueProps>(function Hue(
{ orientation = "horizontal", className, ...rest },
ref,
) {
const { color, format, setColor } = useColorPickerContext();
// Slider tracks the active format's hue scale so the position matches the
// value shown in ChannelInput. OKLCH hue ≠ HSL/HSB hue for the same color
// (red is OKLCH ~29° / HSL 0°), so an OKLCH-driven slider feels broken to
// users editing in HSL/HSB. For formats without a hue channel (hex, rgb,
// p3, oklab) we fall back to OKLCH hue.
const displayedHue = React.useMemo(() => {
if (format === "hsl") return hslHue(color);
if (format === "hsb") return hsbHue(color);
return color.h;
}, [format, color]);
const trackRef = React.useRef<HTMLDivElement | null>(null);
React.useImperativeHandle(ref, () => trackRef.current as HTMLDivElement);
// Chroma rescaling and the HSL/HSB write path live in `setHueFromSlider`
// so this and the Base UI variant share one implementation.
const commitHue = React.useCallback(
(newH: number) =>
setColor(
setHueFromSlider(
color,
Math.max(0, Math.min(HUE_SLIDER_MAX, newH)),
format,
),
),
[color, format, setColor],
);
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));
commitHue(clamped * 360);
};
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 ? 10 : 1;
let next = displayedHue;
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 = 360;
break;
case "PageUp":
next += 25;
break;
case "PageDown":
next -= 25;
break;
default:
return;
}
e.preventDefault();
commitHue(next);
};
const pos = (((displayedHue % 360) + 360) % 360) / 360;
const isVertical = orientation === "vertical";
return (
<div
ref={trackRef}
data-slot="color-picker-hue"
role="slider"
aria-label="Hue"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(displayedHue)}
aria-valuetext={`${Math.round(displayedHue)} degrees`}
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",
// WCAG 2.5.8: the visual track stays 12px thin, but a pseudo-element
// widens the pointer target to 24px on the thin axis.
isVertical
? "h-32 w-3 before:absolute before:-inset-x-1.5 before:content-['']"
: "h-3 w-full before:absolute before:-inset-y-1.5 before:content-['']",
"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, oklch(0.7 0.25 0), oklch(0.7 0.25 60), oklch(0.7 0.25 120), oklch(0.7 0.25 180), oklch(0.7 0.25 240), oklch(0.7 0.25 300), oklch(0.7 0.25 360))"
: "linear-gradient(to right, oklch(0.7 0.25 0), oklch(0.7 0.25 60), oklch(0.7 0.25 120), oklch(0.7 0.25 180), oklch(0.7 0.25 240), oklch(0.7 0.25 300), oklch(0.7 0.25 360))",
}}
{...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 - ${pos}) * (100% - 16px) + 8px)`, background: `oklch(0.7 0.25 ${displayedHue})` }
: { left: `calc(${pos} * (100% - 16px) + 8px)`, top: "50%", background: `oklch(0.7 0.25 ${displayedHue})` }
}
/>
</div>
);
});
registry/new-york/ui/fill-picker/parts/lightness.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "../context";
import { formatColor } from "../lib/color";
import { cn } from "@/lib/utils";
export interface LightnessProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onKeyDown"> {
orientation?: "horizontal" | "vertical";
}
export const Lightness = React.forwardRef<HTMLDivElement, LightnessProps>(
function Lightness({ 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("l", clamped);
};
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.1 : 0.01;
let next = color.l;
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 = 1;
break;
case "PageUp":
next += 0.1;
break;
case "PageDown":
next -= 0.1;
break;
default:
return;
}
e.preventDefault();
setComponent("l", next);
};
const isVertical = orientation === "vertical";
// Build the gradient at the current hue & chroma so users see how lightness
// changes _their_ color, not a generic black→white ramp.
const stops = React.useMemo(() => {
const samples = 8;
const arr: string[] = [];
for (let i = 0; i <= samples; i++) {
const l = i / samples;
arr.push(formatColor({ l, c: color.c, h: color.h, alpha: 1 }, "oklch"));
}
return arr.join(", ");
}, [color.c, color.h]);
return (
<div
ref={trackRef}
data-slot="color-picker-lightness"
role="slider"
aria-label="Lightness"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(color.l * 100)}
aria-valuetext={`${Math.round(color.l * 100)} percent`}
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",
// WCAG 2.5.8: the visual track stays 12px thin, but a pseudo-element
// widens the pointer target to 24px on the thin axis.
isVertical
? "h-32 w-3 before:absolute before:-inset-x-1.5 before:content-['']"
: "h-3 w-full before:absolute before:-inset-y-1.5 before:content-['']",
"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 - ${color.l}) * (100% - 16px) + 8px)`,
background: formatColor({ ...color, alpha: 1 }, "oklch"),
}
: {
left: `calc(${color.l} * (100% - 16px) + 8px)`,
top: "50%",
background: formatColor({ ...color, alpha: 1 }, "oklch"),
}
}
/>
</div>
);
},
);
registry/new-york/ui/fill-picker/parts/alpha.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 } from "../lib/constants";
export interface AlphaProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onKeyDown"> {
orientation?: "horizontal" | "vertical";
}
export const Alpha = React.forwardRef<HTMLDivElement, AlphaProps>(function Alpha(
{ 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;
setComponent("alpha", Math.max(0, Math.min(1, ratio)));
};
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.1 : 0.01;
let next = color.alpha;
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 = 1;
break;
default:
return;
}
e.preventDefault();
setComponent("alpha", next);
};
const isVertical = orientation === "vertical";
const opaque = formatColor({ ...color, alpha: 1 }, "rgb");
const transparent = formatColor({ ...color, alpha: 0 }, "rgb");
return (
<div
ref={trackRef}
data-slot="color-picker-alpha"
role="slider"
aria-label="Opacity"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(color.alpha * 100)}
aria-valuetext={`${Math.round(color.alpha * 100)} percent`}
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",
// WCAG 2.5.8: the visual track stays 12px thin, but a pseudo-element
// widens the pointer target to 24px on the thin axis.
isVertical
? "h-32 w-3 before:absolute before:-inset-x-1.5 before:content-['']"
: "h-3 w-full before:absolute before:-inset-y-1.5 before:content-['']",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
className,
)}
{...rest}
>
<div
aria-hidden="true"
className="absolute inset-0 overflow-hidden rounded-full"
style={{
backgroundImage: CHECKERBOARD_LG,
backgroundSize: "12px 12px",
}}
>
<div
className="absolute inset-0"
style={{
background: isVertical
? `linear-gradient(to top, ${transparent}, ${opaque})`
: `linear-gradient(to right, ${transparent}, ${opaque})`,
}}
/>
</div>
<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 - ${color.alpha}) * (100% - 16px) + 8px)`, background: opaque }
: { left: `calc(${color.alpha} * (100% - 16px) + 8px)`, top: "50%", background: opaque }
}
/>
</div>
);
});
registry/new-york/ui/fill-picker/parts/field-select.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface FieldSelectProps {
/**
* `standalone` (default) — bordered chevron-select matching every
* picker dropdown (`FormatSwitcher`, `TypeSwitcher`, `InterpSwitcher`,
* `RadialSizeSelect`).
*
* `inline` — borderless variant that lives inside a `FieldShell` (used
* by `ChannelInput`'s format toggle on the left).
*/
variant?: "standalone" | "inline";
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
/** Trigger button class — extends the variant's defaults. */
className?: string;
/** Props forwarded to the outer wrapper `<div>`. Use for `data-slot`,
* width overrides, etc. */
wrapperProps?: React.HTMLAttributes<HTMLDivElement> & {
[key: `data-${string}`]: string | undefined;
};
/**
* Class applied to the popover `<SelectContent>`. Defaults to a
* `font-mono text-xs tracking-wide` block so item rows match the
* trigger font.
*/
contentClassName?: string;
"aria-label"?: string;
/** Pass `<SelectItem>`s as children. */
children?: React.ReactNode;
}
/**
* Single source of truth for every dropdown in the picker. Built on
* shadcn `<Select>` (Base UI) so it composes cleanly with the rest of the
* consumer's design system — focus rings, popover surface, item hover
* states, and keyboard navigation are all the standard shadcn behaviors.
*
* Two variants:
* - `standalone` matches the bordered `h-8` field look of the other
* picker inputs (used by `FormatSwitcher`, `TypeSwitcher`).
* - `inline` lives inside a `FieldShell` (used by `ChannelInput`'s
* leading format toggle) — strips border/shadow/ring so the parent
* shell owns the chrome.
*
* The forwarded ref points at the `SelectTrigger` button so consumers
* can imperatively focus it.
*/
export const FieldSelect = React.forwardRef<
HTMLButtonElement,
FieldSelectProps
>(function FieldSelect(
{
variant = "standalone",
value,
defaultValue,
onValueChange,
disabled,
placeholder,
className,
wrapperProps,
contentClassName,
children,
"aria-label": ariaLabel,
},
ref,
) {
const inline = variant === "inline";
const { className: wrapperClassName, ...wrapperRest } = wrapperProps ?? {};
return (
<div
className={cn(
inline
? "relative inline-flex h-full shrink-0 items-center"
: "relative inline-flex items-center",
wrapperClassName,
)}
{...wrapperRest}
>
<Select
value={value}
defaultValue={defaultValue}
onValueChange={(v) => {
if (v != null) onValueChange?.(v as string);
}}
disabled={disabled}
>
<SelectTrigger
ref={ref}
size="sm"
aria-label={ariaLabel}
className={cn(
"font-mono text-xs tracking-wide",
inline
? "h-full rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:border-transparent focus-visible:ring-0 dark:bg-transparent dark:hover:bg-transparent"
: "w-full",
className,
)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent
className={cn("font-mono text-xs tracking-wide", contentClassName)}
>
{children}
</SelectContent>
</Select>
</div>
);
});
registry/new-york/ui/fill-picker/parts/format-switcher.tsx
"use client";
import * as React from "react";
import { SelectItem } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useColorPickerContext } from "../context";
import type { ColorFormat } from "../lib/types";
import { FieldSelect } from "./field-select";
export interface FormatSwitcherProps {
/** Override the formats from <ColorPicker.Root formats={...} />. */
formats?: ColorFormat[];
className?: string;
}
export const FormatSwitcher = React.forwardRef<
HTMLButtonElement,
FormatSwitcherProps
>(function FormatSwitcher({ formats: formatsProp, className }, ref) {
const { format, setFormat, formats: ctxFormats } = useColorPickerContext();
const formats = formatsProp ?? ctxFormats;
return (
<FieldSelect
ref={ref}
aria-label="Color format"
value={format}
onValueChange={(v) => setFormat(v as ColorFormat)}
className="w-full uppercase"
wrapperProps={{
// w-full basis so the trigger's w-full has something to fill —
// without it the inline-flex wrapper sizes to the selected value
// and the control resizes per format (parity with the Base UI
// variant's FormatSwitcher).
className: cn("w-full", className),
"data-slot": "color-picker-format-switcher",
}}
>
{formats.map((f) => (
<SelectItem key={f} value={f} className="uppercase">
{f}
</SelectItem>
))}
</FieldSelect>
);
});
registry/new-york/ui/fill-picker/parts/channel-input.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "../context";
import { parseColor } from "../lib/color";
import {
colorChannels,
setColorChannel,
type ChannelDescriptor,
} from "../lib/channels";
import type { ColorFormat } from "../lib/types";
import { cn } from "@/lib/utils";
import { SelectItem } from "@/components/ui/select";
import {
FieldDivider,
FieldInput,
FieldInputGroup,
FieldShell,
FieldSuffix,
} from "./field";
import { FieldSelect } from "./field-select";
export interface ChannelInputProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
/** Override the formats from <ColorPicker.Root formats={...}>. */
formats?: ColorFormat[];
/** Hide the inline format selector — useful when pairing with a
* standalone <FormatSwitcher /> elsewhere in the layout. */
showFormat?: boolean;
}
/**
* Photoshop-style multi-field input: format selector on the left, one numeric
* field per channel, alpha as %. For `hex` falls back to a single text field
* (no meaningful per-channel breakdown). Pasting any CSS color string into any
* field parses it and replaces the whole color.
*
* Built on the shared field primitives in `./field` so every text input in
* the package shares the same border, focus ring, and chevron.
*/
export const ChannelInput = React.forwardRef<
HTMLDivElement,
ChannelInputProps
>(function ChannelInput(
{ formats: formatsProp, showFormat = true, className, ...rest },
ref,
) {
const {
color,
format,
formatted,
setFormat,
setColor,
setFromString,
formats: ctxFormats,
} = useColorPickerContext();
const formats = formatsProp ?? ctxFormats;
const channels = React.useMemo(
() => colorChannels(color, format),
[color, format],
);
const handleChannelChange = (key: string, value: number) => {
setColor(setColorChannel(color, format, key, value));
};
return (
<FieldShell
ref={ref}
data-slot="color-picker-channel-input"
className={className}
{...rest}
>
{showFormat && (
<>
<FieldSelect
aria-label="Color format"
variant="inline"
value={format}
onValueChange={(v) => setFormat(v as ColorFormat)}
className="uppercase"
wrapperProps={{
"data-slot": "color-picker-channel-input-format",
}}
>
{formats.map((f) => (
<SelectItem key={f} value={f} className="uppercase">
{f}
</SelectItem>
))}
</FieldSelect>
<FieldDivider />
</>
)}
{format === "hex" ? (
<HexField value={formatted} onCommit={setFromString} />
) : (
channels.map((ch, i) => (
<React.Fragment key={ch.key}>
<ChannelField
channel={ch}
onChange={(v) => handleChannelChange(ch.key, v)}
onPasteColor={setFromString}
/>
{i < channels.length - 1 && <FieldDivider />}
</React.Fragment>
))
)}
</FieldShell>
);
});
/* ────────────────────── Hex single field ────────────────────── */
function HexField({
value,
onCommit,
}: {
value: string;
onCommit: (v: string) => boolean;
}) {
// Adjusting state during rendering — React-blessed alternative to
// syncing prop → state in useEffect. When the upstream `value` changes
// (e.g., a sibling control commits), we re-seed the draft synchronously
// *during* the render that observes the change, not after a paint.
const [draft, setDraft] = React.useState(value);
const [prevValue, setPrevValue] = React.useState(value);
const [error, setError] = React.useState(false);
if (value !== prevValue) {
setPrevValue(value);
setDraft(value);
setError(false);
}
const commit = (v: string) => {
const ok = onCommit(v.trim());
setError(!ok);
};
return (
<FieldInput
data-slot="color-picker-channel-input-field"
aria-label="Hex value"
aria-invalid={error || undefined}
value={draft}
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(value);
setError(false);
}
}}
className={cn(
"flex-1 px-2 text-left",
error && "text-destructive",
)}
/>
);
}
/* ────────────────────── Numeric channel field ────────────────────── */
function ChannelField({
channel,
onChange,
onPasteColor,
}: {
channel: ChannelDescriptor;
onChange: (next: number) => void;
onPasteColor: (raw: string) => boolean;
}) {
const display = formatNumber(channel.value, channel.precision);
// Sync draft when external state updates (slider drag, sibling channel,
// format swap) using the in-render adjustment pattern. Comparing formatted
// strings avoids clobbering an in-progress edit when the canonical value
// rounds to the same display string.
const [draft, setDraft] = React.useState(display);
const [prevDisplay, setPrevDisplay] = React.useState(display);
if (display !== prevDisplay) {
setPrevDisplay(display);
setDraft(display);
}
const commit = (raw: string) => {
const parsed = parseFloat(raw);
if (Number.isNaN(parsed)) {
setDraft(display);
return;
}
onChange(parsed);
// A clamped / rounded commit can leave `display` unchanged ("999" → 255
// when R is already 255), which the in-render sync never sees — snap the
// draft back here. If the commit did change `display`, the sync above
// overwrites this on the next render.
setDraft(display);
};
const step = (delta: number) => {
const parsed = parseFloat(draft);
const base = Number.isNaN(parsed) ? channel.value : parsed;
onChange(base + delta);
};
return (
<FieldInputGroup>
<span className="sr-only">{channel.label}</span>
<FieldInput
data-slot="color-picker-channel-input-field"
inputMode="decimal"
aria-label={channel.label}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => commit(e.target.value)}
onPaste={(e) => {
const text = e.clipboardData?.getData("text") ?? "";
if (parseColor(text.trim())) {
e.preventDefault();
onPasteColor(text);
}
}}
onKeyDown={(e) => {
const big = e.shiftKey ? channel.bigStep : channel.step;
if (e.key === "ArrowUp") {
e.preventDefault();
step(big);
} else if (e.key === "ArrowDown") {
e.preventDefault();
step(-big);
} else if (e.key === "Enter") {
e.preventDefault();
commit(e.currentTarget.value);
} else if (e.key === "Escape") {
setDraft(display);
}
}}
/>
{channel.suffix && <FieldSuffix>{channel.suffix}</FieldSuffix>}
</FieldInputGroup>
);
}
/* ────────────────────── Helpers ────────────────────── */
function formatNumber(value: number, precision: number): string {
return precision === 0 ? String(Math.round(value)) : value.toFixed(precision);
}
registry/new-york/ui/fill-picker/parts/swatches.tsx
"use client";
import * as React from "react";
import { Plus } from "lucide-react";
import { useColorPickerContext } from "../context";
import { formatColor, parseColor } from "../lib/color";
import type { OklchColor } from "../lib/types";
import { cn } from "@/lib/utils";
import { CHECKERBOARD_SM, SAMPLE_EDGE } from "../lib/constants";
import {
DEFAULT_SWATCH_PRESETS,
isSameSwatchColor,
} from "../lib/swatch-presets";
export interface SwatchesProps extends React.HTMLAttributes<HTMLDivElement> {
presets?: string[];
/**
* When provided, renders a "+" tile after the presets that calls this with
* the current color. The consumer owns state (lift `presets` and update it
* here, persist to localStorage / a server / Zustand / whatever).
*/
onAdd?: (color: OklchColor, hex: string) => void;
}
export const Swatches = React.forwardRef<HTMLDivElement, SwatchesProps>(function Swatches(
{ presets = DEFAULT_SWATCH_PRESETS, onAdd, className, ...rest },
ref,
) {
const { color, setColor } = useColorPickerContext();
// Roving tabindex per the APG listbox pattern: Tab enters the group once,
// arrows move between options. Focus index survives re-renders but is
// clamped when the presets array shrinks.
const [focusIdx, setFocusIdx] = React.useState(0);
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const roveTo = (idx: number) => {
const clamped = Math.max(0, Math.min(idx, presets.length - 1));
setFocusIdx(clamped);
optionRefs.current[clamped]?.focus();
};
const onOptionKeyDown = (e: React.KeyboardEvent, i: number) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
roveTo(i + 1);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
roveTo(i - 1);
break;
case "Home":
e.preventDefault();
roveTo(0);
break;
case "End":
e.preventDefault();
roveTo(presets.length - 1);
break;
}
};
const isSamePreset = (preset: OklchColor) =>
isSameSwatchColor(preset, color);
return (
<div
ref={ref}
data-slot="color-picker-swatches"
role="listbox"
aria-label="Color swatches"
className={cn("grid grid-cols-10 gap-1", className)}
{...rest}
>
{presets.map((p, i) => {
const parsed = parseColor(p);
const active = parsed ? isSamePreset(parsed) : false;
// Paint the swatch with the raw preset string so out-of-sRGB colors
// (P3 / OKLCH wide-gamut) actually render in their native gamut on
// capable displays. Hex-conversion would clamp them to sRGB.
return (
<button
key={`${p}-${i}`}
ref={(el) => {
optionRefs.current[i] = el;
}}
type="button"
role="option"
aria-selected={active}
aria-label={p}
tabIndex={i === Math.min(focusIdx, presets.length - 1) ? 0 : -1}
onKeyDown={(e) => onOptionKeyDown(e, i)}
onFocus={() => setFocusIdx(i)}
onClick={() => setColor(p)}
className={cn(
// before pseudo-padding: keep the 20px visual chip but give the
// button a 28px hit area (WCAG 2.5.8 target size).
"relative size-5 cursor-pointer rounded-sm outline-none motion-safe:transition-transform",
"before:absolute before:-inset-1 before:content-['']",
SAMPLE_EDGE,
"focus-visible:ring-2 focus-visible:ring-ring motion-safe:hover:scale-110",
active && "ring-2 ring-ring",
)}
style={{ backgroundImage: CHECKERBOARD_SM, backgroundSize: "8px 8px" }}
>
<span
aria-hidden
// No border to inset against any more, so the fill matches the
// tile's own radius exactly.
className="absolute inset-0 rounded-[inherit]"
style={{ background: p }}
/>
</button>
);
})}
{onAdd && (
<button
type="button"
aria-label="Add current color to swatches"
onClick={() => onAdd(color, formatColor(color, "hex"))}
className={cn(
"relative inline-flex size-5 cursor-pointer items-center justify-center rounded-sm border border-dashed border-border text-muted-foreground outline-none motion-safe:transition-colors",
"before:absolute before:-inset-1 before:content-['']",
"hover:border-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Plus className="size-3" />
</button>
)}
</div>
);
});
registry/new-york/ui/fill-picker/parts/gamut-badge.tsx
"use client";
// Radix shell of GamutBadge: consumer's shadcn tooltip + asChild.
// (The shadcn CLI rewrites asChild → render when installing into base-*
// styles.) Shared logic lives in ./gamut-badge-shared.
import * as React from "react";
import { useColorPickerContext } from "../context";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { GAMUT_BADGE_CLASS, gamutLabel } from "./gamut-badge-shared";
import type { GamutBadgeProps } from "./gamut-badge-shared";
export type { GamutBadgeProps };
export const GamutBadge = React.forwardRef<HTMLDivElement, GamutBadgeProps>(function GamutBadge(
{ showLabel = true, className, ...rest },
ref,
) {
const { gamut } = useColorPickerContext();
const label = gamutLabel(gamut);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
ref={ref}
data-slot="color-picker-gamut-badge"
role="status"
aria-live="polite"
tabIndex={0}
className={cn(GAMUT_BADGE_CLASS, className)}
{...rest}
>
{showLabel && <span className="text-muted-foreground">Gamut</span>}
<span className="font-mono font-medium">{label}</span>
</div>
</TooltipTrigger>
<TooltipContent>Color in {label} color space</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
registry/new-york/ui/fill-picker/parts/contrast-readout.tsx
"use client";
// Radix shell of ContrastReadout: consumer's shadcn tooltip + asChild.
// (The shadcn CLI rewrites asChild → render when installing into base-*
// styles.) All contrast logic lives in ./contrast-readout-shared.
import * as React from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
CONTRAST_READOUT_CLASS,
ContrastPopoverPanel,
useContrastReadout,
} from "./contrast-readout-shared";
import type { ContrastMetric, ContrastReadoutProps } from "./contrast-readout-shared";
export type { ContrastMetric, ContrastReadoutProps };
export const ContrastReadout = React.forwardRef<HTMLDivElement, ContrastReadoutProps>(
function ContrastReadout(
{ metrics, defaultMetric, showLabel, showValue, showBadges, className, ...rest },
ref,
) {
const readout = useContrastReadout({
metrics,
defaultMetric,
showLabel,
showValue,
showBadges,
});
if (readout.togglable) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref as React.Ref<HTMLButtonElement>}
data-slot="color-picker-contrast-readout"
type="button"
onClick={readout.cycle}
aria-label={`Contrast: ${readout.summary}. Click to switch to ${readout.nextMetric.toUpperCase()}.`}
className={cn(
CONTRAST_READOUT_CLASS,
"cursor-pointer text-left motion-safe:transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...(rest as React.ButtonHTMLAttributes<HTMLButtonElement>)}
>
{readout.body}
<span aria-hidden="true" className="ml-auto text-muted-foreground">⇅</span>
<span aria-live="polite" className="sr-only">
{readout.cycleAnnouncement}
</span>
</button>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="max-w-[260px] bg-popover p-2.5 text-popover-foreground shadow-md"
>
<ContrastPopoverPanel
title={readout.popover.title}
rows={readout.popover.rows}
fg={readout.fgCss}
bg={readout.bgCss}
footer={`Click to switch to ${readout.nextMetric.toUpperCase()}`}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
ref={ref}
data-slot="color-picker-contrast-readout"
role="group"
tabIndex={0}
aria-label={`Contrast against background: ${readout.summary}`}
className={cn(
CONTRAST_READOUT_CLASS,
"cursor-default outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...rest}
>
{readout.body}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="max-w-[260px] bg-popover p-2.5 text-popover-foreground shadow-md"
>
<ContrastPopoverPanel
title={readout.popover.title}
rows={readout.popover.rows}
fg={readout.fgCss}
bg={readout.bgCss}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
registry/new-york/ui/fill-picker/color-picker.tsx
"use client";
import { Root } from "./parts/root";
import { Area } from "./parts/area";
import { Hue } from "./parts/hue";
import { Lightness } from "./parts/lightness";
import { Chroma } from "./parts/chroma";
import { Alpha } from "./parts/alpha";
import { CssInput } from "./parts/css-input";
import { FormatSwitcher } from "./parts/format-switcher";
import { ChannelInput } from "./parts/channel-input";
import { Swatches } from "./parts/swatches";
import { GamutBadge } from "./parts/gamut-badge";
import { ContrastReadout } from "./parts/contrast-readout";
import { Preview } from "./parts/preview";
import { EyeDropper } from "./parts/eye-dropper";
export type {
ColorFormat,
OklchColor,
GamutInfo,
ContrastResult,
Gamut,
} from "./lib/types";
export type {
UseColorPickerProps,
ColorPickerState,
} from "./hooks/use-color-picker";
export { useColorPicker } from "./hooks/use-color-picker";
export {
parseColor,
formatColor,
formatAll,
gamutInfo,
toGamut,
contrast,
apcaContrast,
isValidColor,
} from "./lib/color";
export { colorChannels, setColorChannel } from "./lib/channels";
export type { ChannelDescriptor } from "./lib/channels";
/**
* Compositional color picker. Compose `<ColorPicker.Root>` with the named
* parts to build the layout you want. Following shadcn convention there is
* no kitchen-sink default component — see the docs for a copy-paste recipe
* of the canonical layout.
*/
export const ColorPicker = {
Root,
Area,
Hue,
Lightness,
Chroma,
Alpha,
CssInput,
FormatSwitcher,
ChannelInput,
Swatches,
GamutBadge,
ContrastReadout,
Preview,
EyeDropper,
};