shadimage
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 · light
iconslucide-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,
};

More from amplo

Browse all components in this library →