shadimage
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 · light
ui-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;

More from amplo

Browse all components in this library →