amplo
uicolor-pickersource: ok · hit
Amplo Color Picker
OKLCH-native, Display-P3-aware color picker built on Base UI primitives — Hue/Lightness/Alpha on Slider, FormatSwitcher on Select, ChannelInput on NumberField, Swatches on RadioGroup. Pulls the shared OKLCH engine and pointer-driven parts (Area, Chroma, GamutBadge, ContrastReadout, Preview, EyeDropper, CssInput) as a registry dependency.
npx shadcn add "https://amplo.ale.design/r/color-picker.json"free
Where it goes
testimonial
Interaction
carouseldropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
iconslucide-react
Registry dependencies
utilshttps://amplo.ale.design/r/color-picker-engine.json
Files10 · source cached 10/8/2026, 7:42:04 PM
registry/new-york/ui/fill-picker-base/parts/hue.tsx
"use client";
import * as React from "react";
import { Slider } from "@base-ui/react/slider";
import { useColorPickerContext } from "@/registry/new-york/ui/fill-picker/context";
import { hslHue, hsbHue } from "@/registry/new-york/ui/fill-picker/lib/color";
import { setHueFromSlider } from "@/registry/new-york/ui/fill-picker/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;
// `defaultValue` is omitted because Base UI's Slider.Root types it as a number
// (its own controlled/uncontrolled value), which conflicts with the string-ish
// `defaultValue` on React.HTMLAttributes.
export interface HueProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue"> {
orientation?: "horizontal" | "vertical";
}
/**
* Base UI port of the hue slider.
*
* The interesting parts that survive the port:
* 1. We still display the *format-active* hue (HSL/HSB hue ≠ OKLCH hue for
* the same color), so the slider position lines up with ChannelInput's H.
* 2. On OKLCH-driven formats we preserve "saturation" (the area bead's X
* position) by rescaling chroma to the new hue's max — green has less
* max chroma than red in P3, so absolute chroma would drift out of gamut.
*
* Base UI gives us keyboard, ARIA, focus management, and pointer handling for
* free. We provide the value pipeline and paint the track + thumb.
*/
export const Hue = React.forwardRef<HTMLDivElement, HueProps>(function Hue(
{ orientation = "horizontal", className, ...rest },
ref,
) {
const { color, format, setColor } = useColorPickerContext();
const displayedHue = React.useMemo(() => {
if (format === "hsl") return hslHue(color);
if (format === "hsb") return hsbHue(color);
return color.h;
}, [format, color]);
// Chroma rescaling and the HSL/HSB write path live in `setHueFromSlider`
// so this and the classic 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 isVertical = orientation === "vertical";
// Vertical uses `to top` so the min (hue 0) is painted at the bottom —
// Base UI's vertical Slider anchors its thumb from the bottom edge
// (startEdge = "bottom"), so a `to bottom` gradient would read inverted.
const gradient = 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))";
return (
<Slider.Root
ref={ref}
data-slot="color-picker-hue"
value={displayedHue}
onValueChange={(v) => commitHue(v as number)}
min={0}
max={360}
step={1}
largeStep={10}
orientation={orientation}
// Keep the thumb inside the track at min/max — the default
// center alignment lets it overhang the rounded track ends.
thumbAlignment="edge"
className={cn(
"relative touch-none select-none",
isVertical ? "h-32 w-3" : "h-3 w-full",
className,
)}
{...rest}
>
<Slider.Control
className={cn(
"relative h-full w-full rounded-full outline-none",
// WCAG 2.5.8: widen the pointer target to 24px on the thin axis.
isVertical
? "before:absolute before:-inset-x-1.5 before:content-['']"
: "before:absolute before:-inset-y-1.5 before:content-['']",
)}
style={{ background: gradient }}
>
<Slider.Thumb
// aria-label + getAriaValueText go on the Thumb: Base UI renders the
// role="slider" input inside the Thumb, so the accessible name and
// value text must live here (not on Slider.Root, which is a group).
// Focus ring uses `has-[:focus-visible]`: the real role="slider"
// <input> is a child of this thumb <div>, so the div itself never
// matches :focus-visible directly. (Base UI's data-focused only
// appears inside a Field.Root, which we don't use.)
aria-label="Hue"
getAriaValueText={(_, value) => `${Math.round(value)} degrees`}
className={cn(
"absolute size-4 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]",
"outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-popover",
)}
style={{ background: `oklch(0.7 0.25 ${displayedHue})` }}
/>
</Slider.Control>
</Slider.Root>
);
});
registry/new-york/ui/fill-picker-base/parts/lightness.tsx
"use client";
import * as React from "react";
import { Slider } from "@base-ui/react/slider";
import { useColorPickerContext } from "@/registry/new-york/ui/fill-picker/context";
import { formatColor } from "@/registry/new-york/ui/fill-picker/lib/color";
import { cn } from "@/lib/utils";
// See Hue: omit `defaultValue` (Slider.Root owns it as a number).
export interface LightnessProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue"> {
orientation?: "horizontal" | "vertical";
}
export const Lightness = React.forwardRef<HTMLDivElement, LightnessProps>(function Lightness(
{ orientation = "horizontal", className, ...rest },
ref,
) {
const { color, setComponent } = useColorPickerContext();
const isVertical = orientation === "vertical";
// Gradient built 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++) {
arr.push(formatColor({ ...color, l: i / samples, alpha: 1 }, "oklch"));
}
return arr.join(", ");
}, [color]);
return (
<Slider.Root
ref={ref}
data-slot="color-picker-lightness"
value={color.l * 100}
onValueChange={(v) => setComponent("l", (v as number) / 100)}
min={0}
max={100}
step={1}
largeStep={10}
orientation={orientation}
// Keep the thumb inside the track at min/max — the default
// center alignment lets it overhang the rounded track ends.
thumbAlignment="edge"
className={cn(
"relative touch-none select-none",
isVertical ? "h-32 w-3" : "h-3 w-full",
className,
)}
{...rest}
>
<Slider.Control
className={cn(
"relative h-full w-full rounded-full outline-none",
// WCAG 2.5.8: widen the pointer target to 24px on the thin axis.
isVertical
? "before:absolute before:-inset-x-1.5 before:content-['']"
: "before:absolute before:-inset-y-1.5 before:content-['']",
)}
style={{
// Vertical uses `to top` so the min (l=0) renders at the bottom to
// match Base UI's bottom-anchored vertical thumb.
background: isVertical
? `linear-gradient(to top, ${stops})`
: `linear-gradient(to right, ${stops})`,
}}
>
<Slider.Thumb
aria-label="Lightness"
getAriaValueText={(_, value) => `${Math.round(value)}%`}
className={cn(
"absolute size-4 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]",
"outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-popover",
)}
style={{ background: formatColor({ ...color, alpha: 1 }, "oklch") }}
/>
</Slider.Control>
</Slider.Root>
);
});
registry/new-york/ui/fill-picker-base/parts/alpha.tsx
"use client";
import * as React from "react";
import { Slider } from "@base-ui/react/slider";
import { useColorPickerContext } from "@/registry/new-york/ui/fill-picker/context";
import { formatColor } from "@/registry/new-york/ui/fill-picker/lib/color";
import { cn } from "@/lib/utils";
import { CHECKERBOARD_LG as CHECKERBOARD } from "@/registry/new-york/ui/fill-picker/lib/constants";
// See Hue: omit `defaultValue` (Slider.Root owns it as a number).
export interface AlphaProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue"> {
orientation?: "horizontal" | "vertical";
}
export const Alpha = React.forwardRef<HTMLDivElement, AlphaProps>(function Alpha(
{ orientation = "horizontal", className, ...rest },
ref,
) {
const { color, setComponent } = useColorPickerContext();
const isVertical = orientation === "vertical";
const opaque = formatColor({ ...color, alpha: 1 }, "rgb");
const transparent = formatColor({ ...color, alpha: 0 }, "rgb");
return (
<Slider.Root
ref={ref}
data-slot="color-picker-alpha"
value={color.alpha * 100}
onValueChange={(v) => setComponent("alpha", (v as number) / 100)}
min={0}
max={100}
step={1}
largeStep={10}
orientation={orientation}
// Keep the thumb inside the track at min/max — the default
// center alignment lets it overhang the rounded track ends.
thumbAlignment="edge"
className={cn(
"relative touch-none select-none",
isVertical ? "h-32 w-3" : "h-3 w-full",
className,
)}
{...rest}
>
<Slider.Control
className={cn(
"relative h-full w-full rounded-full outline-none",
// WCAG 2.5.8: widen the pointer target to 24px on the thin axis.
isVertical
? "before:absolute before:-inset-x-1.5 before:content-['']"
: "before:absolute before:-inset-y-1.5 before:content-['']",
)}
>
<div
aria-hidden="true"
className="absolute inset-0 overflow-hidden rounded-full"
style={{ backgroundImage: CHECKERBOARD, backgroundSize: "12px 12px" }}
>
<div
className="absolute inset-0"
style={{
// Vertical uses `to top` so transparent (alpha 0, the min) sits
// at the bottom to match Base UI's bottom-anchored vertical thumb.
background: isVertical
? `linear-gradient(to top, ${transparent}, ${opaque})`
: `linear-gradient(to right, ${transparent}, ${opaque})`,
}}
/>
</div>
<Slider.Thumb
aria-label="Opacity"
getAriaValueText={(_, value) => `${Math.round(value)}%`}
className={cn(
"absolute size-4 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]",
"outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-popover",
)}
style={{ background: opaque }}
/>
</Slider.Control>
</Slider.Root>
);
});
registry/new-york/ui/fill-picker-base/parts/format-switcher.tsx
"use client";
import * as React from "react";
import { useColorPickerContext } from "@/registry/new-york/ui/fill-picker/context";
import type { ColorFormat } from "@/registry/new-york/ui/fill-picker/lib/types";
import { cn } from "@/lib/utils";
import { FieldSelect, FieldSelectItem } from "./gradient/field";
export interface FormatSwitcherProps
extends Omit<React.ComponentPropsWithoutRef<"button">, "onChange" | "value"> {
formats?: ColorFormat[];
}
/**
* Built on the shared Base UI `FieldSelect` — the single source of truth
* for every dropdown in the picker — so its border, focus ring, mono
* uppercase font, and chevron can't drift from TypeSwitcher /
* InterpSwitcher / RadialSizeSelect.
*/
export const FormatSwitcher = React.forwardRef<
HTMLButtonElement,
FormatSwitcherProps
>(function FormatSwitcher({ formats: formatsProp, className, ...rest }, 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="uppercase"
contentClassName="uppercase"
wrapperProps={{
"data-slot": "color-picker-format-switcher",
// The wrapper is the flex participant — layout classes from the
// caller (flex-1, widths) must land here, or a sibling like the
// flex-1 EyeDropper gets squeezed to min-content by the wrapper's
// w-full basis.
className: cn("w-full", className),
...(rest as React.HTMLAttributes<HTMLDivElement>),
}}
>
{formats.map((f) => (
<FieldSelectItem key={f} value={f} className="uppercase">
{f}
</FieldSelectItem>
))}
</FieldSelect>
);
});
registry/new-york/ui/fill-picker-base/parts/gradient/field.tsx
"use client";
import * as React from "react";
import { Select } from "@base-ui/react/select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
// Base UI variant of the gradient field shell.
//
// FieldShell / FieldDivider / FieldInput / FieldInputGroup / FieldSuffix are
// plain, variant-agnostic markup — re-exported from the original so both
// pickers share one implementation. Only FieldSelect (built on shadcn
// `<Select>` / Radix in the original) is rebuilt here on `@base-ui/react/select`.
export {
FieldShell,
FieldDivider,
FieldInput,
FieldInputGroup,
FieldSuffix,
type FieldInputProps,
} from "@/registry/new-york/ui/fill-picker/parts/field";
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 popup (Base UI `Select.Popup`). Defaults to a
* `font-mono text-xs tracking-wide` block so item rows match the
* trigger font.
*/
contentClassName?: string;
"aria-label"?: string;
/**
* Value → label map for the trigger's collapsed display. Base UI's
* `<Select.Value>` shows the raw value unless the root is told about
* item labels via `items` — pass this whenever an option's display
* label differs from its value (e.g. `"linear"` → `"Linear"`).
*/
items?: Record<string, React.ReactNode>;
/** Pass `<FieldSelectItem>`s as children. */
children?: React.ReactNode;
}
/**
* Single source of truth for every dropdown in the Base UI gradient
* picker. Built on `@base-ui/react/select` so keyboard navigation, focus
* management, and positioning are Base UI's — this file only supplies the
* visual layer, matching the Radix `FieldSelect`'s look pixel-for-pixel.
*
* Mirrors the original `FieldSelect` API exactly (same props, same two
* variants) so gradient parts are drop-in replacements of their Radix
* counterparts.
*
* The forwarded ref points at the trigger button so consumers can
* imperatively focus it, matching the original.
*/
export const FieldSelect = React.forwardRef<
HTMLButtonElement,
FieldSelectProps
>(function FieldSelect(
{
variant = "standalone",
value,
defaultValue,
onValueChange,
disabled,
placeholder,
className,
wrapperProps,
contentClassName,
children,
items,
"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.Root
items={items}
value={value}
defaultValue={defaultValue}
onValueChange={(v) => {
if (v != null) onValueChange?.(v);
}}
disabled={disabled}
>
<Select.Trigger
ref={ref}
aria-label={ariaLabel}
className={cn(
"flex items-center justify-between gap-2 font-mono text-xs tracking-wide outline-none",
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
inline
? "h-full rounded-none border-0 bg-transparent px-2 focus-visible:ring-0"
: "h-8 w-full rounded-md border border-input bg-transparent px-3 shadow-xs focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
className,
)}
>
<Select.Value placeholder={placeholder} />
<Select.Icon>
<ChevronDown className="size-4 shrink-0 opacity-50" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Positioner sideOffset={4} className="z-50 outline-none">
<Select.Popup
className={cn(
"min-w-[var(--anchor-width)] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"font-mono text-xs tracking-wide",
contentClassName,
)}
>
{children}
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
</div>
);
});
export interface FieldSelectItemProps {
value: string;
className?: string;
children?: React.ReactNode;
}
/**
* Base UI counterpart of shadcn's `<SelectItem>` — the item type
* `<FieldSelect>` expects as children. Kept intentionally minimal (value +
* children only) since every gradient dropdown option is plain text or a
* text row with a trailing info tooltip.
*/
export const FieldSelectItem = React.forwardRef<
HTMLDivElement,
FieldSelectItemProps
>(function FieldSelectItem({ value, className, children }, ref) {
return (
<Select.Item
ref={ref}
value={value}
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<Select.ItemIndicator>
<Check className="size-4" />
</Select.ItemIndicator>
</span>
<Select.ItemText>{children}</Select.ItemText>
</Select.Item>
);
});
registry/new-york/ui/fill-picker-base/parts/channel-input.tsx
"use client";
import * as React from "react";
import { NumberField } from "@base-ui/react/number-field";
import { useColorPickerContext } from "@/registry/new-york/ui/fill-picker/context";
import { parseColor } from "@/registry/new-york/ui/fill-picker/lib/color";
import {
colorChannels,
setColorChannel,
type ChannelDescriptor,
} from "@/registry/new-york/ui/fill-picker/lib/channels";
import type { ColorFormat } from "@/registry/new-york/ui/fill-picker/lib/types";
// FieldShell / FieldDivider are plain, Radix-free markup — shared with the
// classic tree so the input chrome has one visual source of truth.
import {
FieldDivider,
FieldShell,
} from "@/registry/new-york/ui/fill-picker/parts/field";
import { cn } from "@/lib/utils";
export interface ChannelInputProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
formats?: ColorFormat[];
showFormat?: boolean;
}
/**
* Base UI port of the Photoshop-style multi-field input, one
* `NumberField.Root` per channel instead of a hand-rolled `<input>`.
*
* The original's "paste any CSS color string into any field" flow survives
* the port: Base UI's `NumberField.Input` has its own internal `onPaste`
* that calls `parseNumber` and bails out via `event.preventDefault()` when
* the clipboard text isn't a plain number. Base UI merges the props we pass
* to `NumberField.Input` *before* its internal handlers (rightmost prop set
* wins / runs first — see `mergeProps` in `@base-ui/react/merge-props`), so
* our own `onPaste` runs first. When the pasted text parses as a full CSS
* color we call `setFromString` and `event.preventDefault()` ourselves,
* which makes the internal handler's own `event.defaultPrevented` guard
* bail out — the number field never sees the paste. Plain numeric pastes
* (e.g. "128" into the R field) fall through untouched to Base UI's default
* numeric paste handling.
*/
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 && (
<>
<FormatSelect format={format} formats={formats} onChange={setFormat} />
<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>
);
},
);
/* ────────────────────── Format select ────────────────────── */
function FormatSelect({
format,
formats,
onChange,
}: {
format: ColorFormat;
formats: ColorFormat[];
onChange: (next: ColorFormat) => void;
}) {
// Kept as a plain <select> rather than Base UI's Select: this is a dense
// inline control inside the channel-input row, and the standalone
// FormatSwitcher part already demonstrates the Base UI Select port.
return (
<div className="relative inline-flex shrink-0 items-center">
<select
data-slot="color-picker-channel-input-format"
aria-label="Color format"
value={format}
onChange={(e) => onChange(e.target.value as ColorFormat)}
className="h-full appearance-none bg-transparent pl-2 pr-5 font-mono text-xs uppercase tracking-wide outline-none"
>
{formats.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
<svg
aria-hidden="true"
viewBox="0 0 12 12"
className="pointer-events-none absolute right-1.5 size-3 text-muted-foreground"
>
<path
d="M3 4.5l3 3 3-3"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
/* ────────────────────── Hex single field ────────────────────── */
function HexField({
value,
onCommit,
}: {
value: string;
onCommit: (v: string) => boolean;
}) {
// Hex has no per-channel breakdown, so it stays a plain text field — same
// as the original — rather than a NumberField.
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 (
<input
data-slot="color-picker-channel-input-field"
type="text"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
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(
"min-w-0 flex-1 bg-transparent px-2 outline-none",
error && "text-destructive",
)}
/>
);
}
/* ────────────────────── Numeric channel field (NumberField) ────────────────────── */
function ChannelField({
channel,
onChange,
onPasteColor,
}: {
channel: ChannelDescriptor;
onChange: (next: number) => void;
onPasteColor: (raw: string) => boolean;
}) {
return (
<NumberField.Root
value={channel.value}
onValueChange={(v) => {
if (v !== null) onChange(v);
}}
min={Number.isFinite(channel.min) ? channel.min : undefined}
max={Number.isFinite(channel.max) ? channel.max : undefined}
step={channel.step}
largeStep={channel.bigStep}
allowWheelScrub
format={{
maximumFractionDigits: channel.precision,
minimumFractionDigits: channel.precision,
}}
className="relative inline-flex h-full min-w-0 flex-1 items-center"
>
<NumberField.Input
data-slot="color-picker-channel-input-field"
aria-label={channel.label}
onPaste={(e) => {
const text = e.clipboardData?.getData("text") ?? "";
if (parseColor(text.trim())) {
e.preventDefault();
onPasteColor(text);
}
// Otherwise fall through to NumberField's own numeric paste
// handling (it bails out on its own when `defaultPrevented`).
}}
// text-right to match the classic FieldInput — values sit against
// their suffix the same way in both variants.
className="w-full min-w-0 bg-transparent px-1.5 text-right outline-none tabular-nums"
/>
{channel.suffix && (
<span
aria-hidden
className="pointer-events-none pr-1.5 text-muted-foreground"
>
{channel.suffix}
</span>
)}
</NumberField.Root>
);
}
registry/new-york/ui/fill-picker-base/parts/swatches.tsx
"use client";
import * as React from "react";
import { RadioGroup } from "@base-ui/react/radio-group";
import { Radio } from "@base-ui/react/radio";
import { Plus } from "lucide-react";
import { useColorPickerContext } from "@/registry/new-york/ui/fill-picker/context";
import { formatColor, parseColor } from "@/registry/new-york/ui/fill-picker/lib/color";
import type { OklchColor } from "@/registry/new-york/ui/fill-picker/lib/types";
import { cn } from "@/lib/utils";
import {
CHECKERBOARD_SM as CHECKERBOARD,
SAMPLE_EDGE,
} from "@/registry/new-york/ui/fill-picker/lib/constants";
import {
DEFAULT_SWATCH_PRESETS,
isSameSwatchColor,
} from "@/registry/new-york/ui/fill-picker/lib/swatch-presets";
export interface SwatchesProps extends React.HTMLAttributes<HTMLDivElement> {
presets?: string[];
onAdd?: (color: OklchColor, hex: string) => void;
}
// Sentinel RadioGroup value used when the current color doesn't match any
// preset — keeps every Radio unchecked without needing an `undefined` value
// (RadioGroup treats `undefined` as uncontrolled).
const NONE = "__none__";
/**
* Base UI port of Swatches: a swatch grid is exactly a single-select radio
* group, so each preset becomes a `Radio.Root` styled as the color tile and
* the grid itself is `RadioGroup`. This buys keyboard roving-tabindex
* (arrow keys move focus + selection between swatches) and proper
* radiogroup/radio ARIA semantics for free, replacing the original's
* hand-rolled `role="listbox"`/`role="option"` buttons.
*
* The "+" add-current-color tile is intentionally *not* a radio option — it
* performs an action (append a preset) rather than selecting one — so it
* stays a plain button rendered as a sibling of the RadioGroup.
*/
export const Swatches = React.forwardRef<HTMLDivElement, SwatchesProps>(function Swatches(
{ presets = DEFAULT_SWATCH_PRESETS, onAdd, className, ...rest },
ref,
) {
const { color, setColor } = useColorPickerContext();
const activeValue = React.useMemo(() => {
for (const p of presets) {
const parsed = parseColor(p);
if (parsed && isSameSwatchColor(parsed, color)) return p;
}
return NONE;
}, [presets, color]);
return (
<div
ref={ref}
data-slot="color-picker-swatches"
className={cn("grid grid-cols-10 gap-1", className)}
{...rest}
>
<RadioGroup
aria-label="Color swatches"
value={activeValue}
onValueChange={(value) => setColor(value as string)}
// "contents" keeps RadioGroup's own wrapping <div> out of the grid
// box model — its Radio children lay out as direct grid items,
// matching the original's flat 10-column swatch grid.
className="contents"
>
{presets.map((p, i) => (
<Radio.Root
key={`${p}-${i}`}
value={p}
aria-label={p}
className={cn(
// before pseudo-padding: 20px visual chip, 28px hit area
// (WCAG 2.5.8). No overflow-hidden — it would clip the inset.
"relative size-5 cursor-pointer rounded-sm outline-none transition-transform",
"before:absolute before:-inset-1 before:content-['']",
SAMPLE_EDGE,
"focus-visible:ring-2 focus-visible:ring-ring hover:scale-110",
"data-[checked]:ring-2 data-[checked]:ring-ring",
)}
style={{ backgroundImage: CHECKERBOARD, 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 }}
/>
</Radio.Root>
))}
</RadioGroup>
{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 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-base/parts/gamut-badge.tsx
"use client";
// Base UI shell of GamutBadge: owns @base-ui/react/tooltip directly
// (delay / render) instead of the consumer's shadcn tooltip wrapper, whose
// prop dialect differs between Radix and Base UI styles. Shared logic lives
// in the color-picker tree's gamut-badge-shared.
import * as React from "react";
import { Tooltip } from "@base-ui/react/tooltip";
import { cn } from "@/lib/utils";
import { useColorPickerContext } from "@/registry/new-york/ui/fill-picker/context";
import {
GAMUT_BADGE_CLASS,
gamutLabel,
} from "@/registry/new-york/ui/fill-picker/parts/gamut-badge-shared";
import type { GamutBadgeProps } from "@/registry/new-york/ui/fill-picker/parts/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);
// delay=0: parity with the Radix shells, which inherit the shadcn wrapper default (0). Base UI's own default is 600.
return (
<Tooltip.Provider delay={0}>
<Tooltip.Root>
<Tooltip.Trigger
render={
<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>
}
/>
<Tooltip.Portal>
<Tooltip.Positioner side="top" align="center" sideOffset={4} className="z-50">
<Tooltip.Popup className="z-50 w-fit rounded-md bg-foreground px-3 py-1.5 text-xs text-background">
Color in {label} color space
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
});
registry/new-york/ui/fill-picker-base/parts/contrast-readout.tsx
"use client";
// Base UI shell of ContrastReadout: owns @base-ui/react/tooltip directly
// (delay / render) instead of the consumer's shadcn tooltip wrapper, whose
// prop dialect differs between Radix and Base UI styles. All contrast logic
// lives in the color-picker tree's contrast-readout-shared.
import * as React from "react";
import { Tooltip } from "@base-ui/react/tooltip";
import { cn } from "@/lib/utils";
import {
CONTRAST_READOUT_CLASS,
ContrastPopoverPanel,
useContrastReadout,
} from "@/registry/new-york/ui/fill-picker/parts/contrast-readout-shared";
import type {
ContrastMetric,
ContrastReadoutProps,
} from "@/registry/new-york/ui/fill-picker/parts/contrast-readout-shared";
export type { ContrastMetric, ContrastReadoutProps };
const POPUP_CLASS =
"z-50 max-w-[260px] rounded-md border border-border bg-popover p-2.5 text-xs text-popover-foreground shadow-md";
function ReadoutTooltip({
trigger,
children,
}: {
trigger: React.ReactElement<Record<string, unknown>>;
children: React.ReactNode;
}) {
// delay=0: parity with the Radix shells, which inherit the shadcn wrapper default (0). Base UI's own default is 600.
return (
<Tooltip.Provider delay={0}>
<Tooltip.Root>
<Tooltip.Trigger render={trigger} />
<Tooltip.Portal>
<Tooltip.Positioner side="top" align="center" sideOffset={4} className="z-50">
<Tooltip.Popup className={POPUP_CLASS}>{children}</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
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 (
<ReadoutTooltip
trigger={
<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>
}
>
<ContrastPopoverPanel
title={readout.popover.title}
rows={readout.popover.rows}
fg={readout.fgCss}
bg={readout.bgCss}
footer={`Click to switch to ${readout.nextMetric.toUpperCase()}`}
/>
</ReadoutTooltip>
);
}
return (
<ReadoutTooltip
trigger={
<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>
}
>
<ContrastPopoverPanel
title={readout.popover.title}
rows={readout.popover.rows}
fg={readout.fgCss}
bg={readout.bgCss}
/>
</ReadoutTooltip>
);
},
);
registry/new-york/ui/fill-picker-base/color-picker.tsx
"use client";
// Base UI variant of the color picker.
//
// Reuses the engine + presentational parts from the original color-picker.
// Only the parts that benefit from Base UI primitives are rewritten:
// - Hue, Lightness, Alpha → Slider
// - FormatSwitcher → Select
// - ChannelInput → NumberField (per channel)
// - Swatches → RadioGroup + Radio
// - GamutBadge, ContrastReadout → own @base-ui/react/tooltip shells
// (shared logic in color-picker/parts/*-shared)
//
// Everything else (Root/context, Area canvas, Chroma,
// Preview, EyeDropper, CssInput) is identical to the original; importing
// from the same source means engine fixes propagate to both variants for
// free.
import { Root } from "@/registry/new-york/ui/fill-picker/parts/root";
import { Area } from "@/registry/new-york/ui/fill-picker/parts/area";
import { Chroma } from "@/registry/new-york/ui/fill-picker/parts/chroma";
import { CssInput } from "@/registry/new-york/ui/fill-picker/parts/css-input";
import { Preview } from "@/registry/new-york/ui/fill-picker/parts/preview";
import { EyeDropper } from "@/registry/new-york/ui/fill-picker/parts/eye-dropper";
import { Hue } from "./parts/hue";
import { Lightness } from "./parts/lightness";
import { Alpha } from "./parts/alpha";
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";
export type {
ColorFormat,
OklchColor,
GamutInfo,
ContrastResult,
Gamut,
} from "@/registry/new-york/ui/fill-picker/lib/types";
export type {
UseColorPickerProps,
ColorPickerState,
} from "@/registry/new-york/ui/fill-picker/hooks/use-color-picker";
export { useColorPicker } from "@/registry/new-york/ui/fill-picker/hooks/use-color-picker";
export {
parseColor,
formatColor,
formatAll,
gamutInfo,
toGamut,
contrast,
apcaContrast,
isValidColor,
} from "@/registry/new-york/ui/fill-picker/lib/color";
export {
colorChannels,
setColorChannel,
} from "@/registry/new-york/ui/fill-picker/lib/channels";
export type { ChannelDescriptor } from "@/registry/new-york/ui/fill-picker/lib/channels";
export const ColorPickerBase = {
Root,
Area,
Chroma,
Hue,
Lightness,
Alpha,
CssInput,
FormatSwitcher,
ChannelInput,
Swatches,
GamutBadge,
ContrastReadout,
Preview,
EyeDropper,
};
// Alias so consuming code reads the same as the Radix variant — only the
// import path differs (`.../fill-picker-base/color-picker`).
export const ColorPicker = ColorPickerBase;