← Home

Color Picker

Playground →

OKLCH-native, Display-P3-aware color / fill picker. Composable, accessible, gamut-aware. Drop into any Next.js + Tailwind v4 app with a single shadcn CLI command — pick the entry point that fits.

For AI agents:View as markdown
sRGB

Installation

Three install entry points — pick the smallest one that covers what you need. Each is a single shadcn CLI command; the deeper picks pull the lighter ones as registry dependencies, so you never duplicate files. Everything lands in components/ui/fill-picker/.

Color picker only

Solid color: Area, Hue, Lightness, Chroma, Alpha, CssInput, FormatSwitcher, ChannelInput, Swatches, GamutBadge, ContrastReadout, Preview, EyeDropper.

$ pnpm dlx shadcn@latest add https://amplo.ale.design/r/color-picker-radix.json

Gradient picker (includes color)

Adds linear / radial / conic gradient editing: TypeSwitcher, ShapeSwitcher, Area, Bar, Overlay, AnglePad/Input/Group, PositionPad/Input/Group, RadiusInput, EllipseRadiiInput, RadialSizeSelect, StopList, StopColor, InterpSwitcher, RepeatingToggle, ReverseStops, Presets, CssInput. Pulls color-picker-radix automatically.

$ pnpm dlx shadcn@latest add https://amplo.ale.design/r/gradient-picker-radix.json

Fill switcher (everything)

Adds the color/gradient mode switcher on top: FillPicker.Root, Tabs, Tab, Pane. Pulls the -radix color + gradient items automatically.

$ pnpm dlx shadcn@latest add https://amplo.ale.design/r/fill-picker-radix.json

Installs need shadcn CLI 4.7.0 or newer — files land relative to your components.json aliases, which older CLIs can't resolve. Running npx shadcn@latest (as the commands above do) always satisfies that.

Both variants share one OKLCH engine, so behavior and fixes stay in lockstep. Use the toggle above to switch between the Base UI docs and the Radix / shadcn-classic docs — every live example and code snippet on this page follows the active variant.

Usage

OKLCH is the lossless source of truth. Pass an OklchColor object as value for full fidelity, or any CSS Color 4 string for input convenience. Every change emits the canonical color plus a pre-serialized formats record, so a fallback (e.g. hex) is always one property access away.

import * as React from "react";
import { ColorPicker, parseColor } from "@/components/ui/fill-picker/color-picker";

export function Example() {
  // Store the canonical OklchColor; derive any string output from `formats`.
  const [color, setColor] = React.useState(() => parseColor("oklch(0.7 0.18 30)")!);
  const [hex, setHex] = React.useState("#cf6f4f");
  return (
    <ColorPicker.Root
      value={color}
      onValueChange={(next, _formatted, formats) => {
        setColor(next);
        setHex(formats.hex); // fallback always available
      }}
      backgroundColor="#ffffff"
    >
      <ColorPicker.Area />
      <ColorPicker.Hue />
      <ColorPicker.ChannelInput />
    </ColorPicker.Root>
  );
}

Examples

HSV-style area

areaMode="hsv-sv" anchors the top-left corner to white and top-right to fully saturated, like Photoshop or Framer.

sRGB

Chroma × hue area

When the area is oklch-hc, swap the Hue slider for a Lightness slider and prune the parts you don't need.

sRGB

Soft-proof out-of-display colors

When the render gamut exceeds your monitor's (e.g. rec2020 on a P3 display), unrenderable colors fall back to per-channel RGB clipping by default — that produces a hue-shifted, posterized strip near the canvas edge that doesn't correspond to any real wide-gamut color. softProof flips the strategy: out-of-display samples are chroma-reduced in OKLCH, preserving hue and lightness while the chroma envelope flattens to your monitor's surface. Same OKLCH value gets committed when you click — the proof only changes how unrenderable regions are *painted*, not how they're authored. Compare the two pickers below; differences are clearest at the right edge in rec2020 mode.

softProof off (per-channel clip)
softProof on (chroma reduced in OKLCH)

Inside a Popover trigger

Wrap the picker in a button-driven popover when screen real estate matters. Radix collision detection picks the best side automatically.

User-saved swatches

Lift the presets array and pass onAdd — the consumer owns persistence. Below is a localStorage demo; swap setItem for fetch() to save server-side.

Gradient picker

<GradientPicker.Root> manages a Gradient value (linear, radial, or conic) and exposes the same composable part pattern as the color picker. Combine it with <ColorPicker.*> parts inside <GradientPicker.StopColor> to build any depth of stop-color editor you need.

Compact

A gradient bar with a type switcher and a lightweight Hue + ChannelInput stop editor — the minimum viable gradient picker.

Full

All gradient parts composed together: type switcher, bar, the visual Area pad, shape controls (angle / position / radii), stop list, full color editor, interpolation switcher, and presets.

Bar-only

Just the gradient bar. With editOnClick, tapping a handle opens the same color editor popover the StopList uses; drag still repositions the stop (movement-based detection — flicking a handle a pixel starts a drag, not the popover).

Bar stops. Drag a handle to reposition; drag below the bar (~24px) to remove. Pass editOnClick on <GradientPicker.Bar> to open the stop-color popover on a tap (click-vs-drag is movement- based — flicking a handle a pixel counts as a drag, not a tap). Keyboard. Bar stops respond to ← / → (±1%, ±5% with Shift) and Delete / Backspace to remove. AnglePad / AngleInput respond to arrow keys (±1°, ±15° with Shift) and Home / End. Every Area handle is focusable and editable from the keyboard too — see the section below. StopList is a listbox — Enter or Space select the focused row, Delete / Backspace removes it. Every interactive part exposes a data-slot attribute (gradient-bar, gradient-area, gradient-angle-pad, gradient-position-pad, gradient-stop-list, etc.) for unstyled-targetable composition.

Visual Area pad

<GradientPicker.Area> is a 2D pad that paints the live gradient and overlays draggable handles on top — a visual complement to the standalone shape-control primitives (<GradientPicker.AnglePad>, <GradientPicker.PositionPad>, and friends). The handles are type-adaptive:

  • Linear. Two endpoint handles connected by a dashed line. By default the line passes through the box center and dragging rotates gradient.angle. The first free drag promotes the gradient to positioned mode by setting gradient.start and gradient.end — endpoints can then sit anywhere inside the box, and the line follows them. CSS linear-gradient cannot represent an offset line natively, so on emit the angle is derived from end − start and stop positions are re-mapped into the projected segment, giving the visual offset for free in pure CSS. parseGradient cannot recover start/end from the emitted form — they are dropped on round-trip through a CSS string.setAngle (e.g. via <GradientPicker.AngleInput>) clears both endpoints to return to the angle-only model.
  • Radial. Center handle for gradient.center plus an edge handle that drives gradient.radii — an optional { x, y } pair (fractions of box width / height) that overrides the keyword shape + size form at emit time. For shape: "circle", radiusPx (absolute px) is emitted instead so the gradient stays visually circular regardless of container aspect. Until the user touches the edge handle, both stay undefined and the keyword form is emitted verbatim.
  • Conic. Center handle plus a dial handle locked on a ring around it; rotating the dial drives gradient.startAngle.

Keyboard. Every handle is a focusable <button> with appropriate role / aria-*. The conic dial — and the linear endpoints in their angle-only default mode — nudge the angle by ±1° (±15° with Shift), Home / End jump to 0° / 360° (wraps to 0°). Once a linear gradient is in positioned mode, the linear endpoint arrows nudge the corresponding endpoint by ±1% (±5% with Shift) in the box. The center handle nudges gradient.center by ±1% per arrow (±5% with Shift), Home recenters to (50%, 50%). The radial edge handle nudges gradient.radii by ±1% (±5% with Shift); Shift+drag on pointer locks the ellipse to a visual circle on screen.

Overlay (handles on your canvas)

<GradientPicker.Overlay> is <GradientPicker.Area> minus the painted background — just the dashed line, endpoint handles, middle stop swatches, and (for radial / conic) the center + edge/dial handles. Drop it over any element you control on a canvas and the user can edit the gradient directly on the object it will be applied to. Works identically for linear, radial, and conic gradients; same context, same setters, same keyboard.

The host container must establish a positioning context (e.g. position: relative) and represent the same coordinate space the gradient will be applied to. The overlay’s root is pointer-events-none so empty regions pass clicks through to the canvas object beneath; the handle buttons themselves are pointer-events-auto so they always receive input. If the host is transformed (rotated / scaled), handle positions will not align — keep transforms off the host.

Overlay on a custom canvas object

The same handles as Area, but the painted gradient lives on a consumer-owned div instead of inside the picker. Use this when you want users to edit the gradient directly on the object on their canvas.

Radial size and extent keywords

CSS radial-gradient can describe the ellipse in two ways: an extent keyword that auto-resizes the ellipse relative to the gradient box, or explicit <length-percentage>{1,2} radii. The picker preserves both forms via the optional gradient.radii field — when set, explicit numeric radii are emitted; when unset, the keyword form is emitted from shape + size.

PropTypeDefaultDescription
"closest-side"closest-sideEllipse / circle ends at the nearest box edge. Smallest, snuggest gradient — the inner colors get the most room.
"closest-corner"closest-cornerEnds at the closest corner of the box. Slightly larger than closest-side. Rarely used directly but kept for round-tripping handwritten CSS.
"farthest-side"farthest-sideEnds at the farthest edge of the box. Bigger than closest-corner but smaller than farthest-corner.
"farthest-corner"farthest-cornerdefaultEnds at the farthest corner of the box. Fills the most space — the CSS default.

All four keywords are typed as RadialSizeKeyword (exported from the picker barrel) and are round-tripped by parseGradient formatGradient. The two extremes — closest-side and farthest-corner — cover most use cases; closest-corner and farthest-side are there for round-tripping handwritten CSS without losing information.

Interpolation

<GradientPicker.InterpSwitcher> is a native <select> bound to gradient.interp. It picks the color space the browser blends through between stops — stop positions and stop colors are untouched. Each option emits the matching CSS Color 4 in <space> clause when the gradient is serialized:

PropTypeDefaultDescription
"oklch"in oklchdefaultPerceptually uniform polar interpolation. Hue arcs the short way; chroma/lightness blend evenly. No muddy mid-tones; the canonical default and what every example in this site uses.
"oklab"in oklabPerceptually uniform but cartesian (no hue circle). Slightly less vivid than oklch for far-apart hues but avoids hue overshoot when stops sit on opposite sides of the wheel.
"srgb"in srgbPer-channel linear interpolation in sRGB. Matches the legacy browser default; produces gray/muddy midpoints for far-apart hues. Use when you need byte-identical output to a pre-CSS-Color-4 design tool.
"hsl"in hslHSL interpolation taking the *shorter* path around the hue wheel. Stays vivid through the midpoint (no gray) but can produce surprising hue choices when stops are nearly opposite.
"hsl-longer"in hsl longer hueHSL interpolation forced to take the *longer* arc. Two close hues sweep through the entire opposite half of the wheel — the rainbow effect.

Default is oklch. The component reads from and writes to <GradientPicker.Root> context (throws if rendered outside it). It accepts every standard SelectHTMLAttributes prop (className, disabled, onBlur, etc.) and forwards a ref to the underlying <select>; value, onChange, and aria-label are managed internally and any passed values for those will be overwritten.

Stop editor (StopList)

<GradientPicker.StopList> is a keyboard-driven listbox of the gradient’s stops. Each row has four parts: a swatch (click → full ColorPicker popover bound to that stop), a numeric % position input, an inline color text field that accepts any CSS color string, and a remove button. A trailing + Add stop button inserts a new stop halfway between the selected stop and its next neighbor (or halfway to the end of the bar when the selection is the last stop), sampling the existing ramp so the inserted color blends in.

Per-stop format. Each row tracks its own display format. Changing format in a stop’s popover (or in a sibling <GradientPicker.StopColor>’s FormatSwitcher, which is bound to the selected stop) only updates that one row — the others stay on whatever format they had. New stops mount on defaultStopColorFormat (default oklch, lossless across the full P3/Rec.2020 gamut that stop colors can occupy). Override on the parent: <GP.Root defaultStopColorFormat="hex">.

Pastable formats. The inline field parses anything CSS Color 4 understands — #ff8800, rgb(255 136 0 / 0.8), hsl(30 100% 50%), hwb(30 0% 0%), lab(…), lch(…), oklab(…), oklch(…), color(display-p3 1 0.4 0), color(rec2020 …), color(srgb-linear …), color(prophoto-rgb …), color(xyz …), named colors (rebeccapurple, transparent), and more. Enter commits, Escape reverts, blur commits; invalid input reverts to the last good value. Parsed colors are stored as canonical OKLCH so wide-gamut input survives.

Keyboard. Enter / Space selects the focused row; Delete / Backspace removes it. The numeric position field accepts arrow nudges via the underlying field primitive.

Props. showAddStop (default true) toggles the trailing add-stop button. The initial per-stop format comes from the parent: <GradientPicker.Root defaultStopColorFormat> (default oklch).

Output: state shape + CSS

<GradientPicker.Root onValueChange={…}> fires with a single Gradient argument — a discriminated union (linear / radial / conic). Stops carry canonical OklchColor objects ({ l, c, h, alpha }), not strings. Wide-gamut chroma you authored (via color(display-p3 …), hand-cranked OKLCH, etc.) is preserved verbatim — no sRGB clamp on the way in.

type Gradient = LinearGradient | RadialGradient | ConicGradient;

interface LinearGradient {
  type: "linear";
  angle: number;                    // 0..360, 0 = up
  stops: GradientStop[];
  interp: GradientInterp;
  repeating?: boolean;
  start?: { x: number; y: number }; // optional positioned line, 0..1
  end?:   { x: number; y: number };
}

interface RadialGradient {
  type: "radial";
  shape: "circle" | "ellipse";
  center: { x: number; y: number }; // 0..1
  size: RadialSizeKeyword;
  stops: GradientStop[];
  interp: GradientInterp;
  repeating?: boolean;
  radii?: { x: number; y: number }; // explicit ellipse radii (fractions)
  radiusPx?: number;                // explicit circle radius (aspect-safe)
}

interface ConicGradient {
  type: "conic";
  startAngle: number;               // degrees
  center: { x: number; y: number };
  stops: GradientStop[];
  interp: GradientInterp;
  repeating?: boolean;
}

interface GradientStop {
  color: OklchColor;                // { l, c, h, alpha }, unbounded
  position: number;                 // 0..1
  hint?: number;                    // CSS midpoint hint, 0..1
  id?: string;                      // opt-in stable identity (controlled mode)
}

// Without `id`, a controlled picker reconciles your `value` by position and
// array index — which can't tell two stops sharing a position apart. Give each
// stop a stable id and selection, per-stop format, and colors follow the right
// stop through any reorder. Ids must be unique within the gradient.
//
// Ids round-trip: once you supply them, onValueChange echoes them back (new
// stops included), so `onValueChange={g => setG(g)}` keeps identity. Omit
// them and nothing changes — no `id` ever appears in what the picker emits.

CSS string. Already exported from the public barrel — no need to roll your own serializer. Store the Gradient object as your source of truth (lossless, JSON-serializable, easy to edit) and format to CSS at render time:

import { GradientPicker, formatGradient } from "amplo-color-picker";

function MyPicker() {
  const [g, setG] = useState(DEFAULT_LINEAR);
  return (
    <>
      <GradientPicker.Root value={g} onValueChange={setG}>
        <GradientPicker.Bar />
        <GradientPicker.StopList />
      </GradientPicker.Root>

      {/* Paint the live gradient anywhere */}
      <div style={{ background: formatGradient(g) }} />
    </>
  );
}

Round-trip. parseGradient(formatGradient(g))g — visually lossless. Stop colors are always serialized as oklch(…) regardless of the UI display format (the display format only affects what you read in the StopList; CSS emit stays canonical so wide-gamut chroma survives). start/end endpoints on positioned linears are dropped (CSS can’t represent an offset line; the angle + projected stop positions preserve the visual offset). Hue, lightness, and stop positions round-trip exactly.

Wide gamut. The picker is wide-gamut-first: internal state is unbounded OKLCH, gradient CSS always emits stops as oklch(…) (no sRGB cap, no chroma loss), interpolation defaults to in oklch (vivid midpoints, not muddy), and the area canvas paints in display-p3 on capable displays. The StopList display format is purely a read-out — flipping it to hex or p3 changes only what the inline row text shows; CSS output and stored color are untouched.

Fill picker (tabs)

<FillPicker.Root> bundles solid color and gradient into a single component with <FillPicker.Tabs> / <FillPicker.Tab> on top and a <FillPicker.Pane mode=...> per side. The value is a Fill discriminated union ( { kind: "color", color } or { kind: "gradient", gradient }); each pane mounts its own picker bound to the matching slice, and switching tabs preserves both sides.

Solid + Gradient

Tabs inside the picker. The Solid pane uses the canonical color tree; the Gradient pane uses the full gradient tree. One state, one component.

sRGB

Gradient shape controls

Three Figma layouts compose from the new shape-control primitives. Drop in the recipe that matches your gradient type.

Linear

<GradientPicker.Root>
  {/* …area, bar, stops, etc… */}
  <GradientPicker.AngleGroup>
    <GradientPicker.AnglePad />
    <GradientPicker.AngleInput className="flex-1" />
  </GradientPicker.AngleGroup>
</GradientPicker.Root>

Radial · Circle

<GradientPicker.Root>
  {/* …area, bar, stops, etc… */}
  <div className="flex flex-col gap-2">
    <GradientPicker.ShapeSwitcher />
    <GradientPicker.PositionGroup>
      <GradientPicker.PositionPad />
      <GradientPicker.PositionInput />
      <span className="text-xs text-muted-foreground">Radii</span>
      <GradientPicker.RadiusInput className="flex-1" />
    </GradientPicker.PositionGroup>
  </div>
</GradientPicker.Root>

Radial · Ellipse

<GradientPicker.Root>
  {/* …area, bar, stops, etc… */}
  <div className="flex flex-col gap-2">
    <GradientPicker.ShapeSwitcher />
    <GradientPicker.PositionGroup>
      <GradientPicker.PositionPad />
      <GradientPicker.PositionInput />
    </GradientPicker.PositionGroup>
  </div>
</GradientPicker.Root>

Anatomy

Following shadcn convention, there is no kitchen-sink default component — compose <ColorPicker.Root> with the parts you need. The tree below is a complete reference of what the registry ships.

<ColorPicker.Root>
  <ColorPicker.Area />
  <ColorPicker.Preview />
  <ColorPicker.Hue />
  <ColorPicker.Lightness />     {/* used with Area mode="oklch-hc" */}
  <ColorPicker.Chroma />        {/* OKLCH chroma slider, 0..0.4 */}
  <ColorPicker.Alpha />
  <ColorPicker.EyeDropper />
  <ColorPicker.GamutBadge />
  <ColorPicker.ChannelInput />  {/* format dropdown + per-channel fields */}
  <ColorPicker.FormatSwitcher /> {/* alt: standalone format select */}
  <ColorPicker.CssInput />       {/* alt: single CSS-string text field */}
  <ColorPicker.ContrastReadout />
  <ColorPicker.Swatches />
</ColorPicker.Root>

API: <ColorPicker.Root>

PropTypeDefaultDescription
valuestring | OklchColorControlled value. Pass an OklchColor object for lossless control (recommended); strings work too but lose hue when gamut-clipped to gray/black/white. The picker keeps a sticky-hue fallback for string inputs to mitigate that.
defaultValuestring | OklchColorUncontrolled initial value.
onValueChange(color, formatted, formats) => voidFires on every change. `color` is the canonical OklchColor. `formatted` is the active format's string. `formats` is a Record<ColorFormat, string> with every supported format pre-serialized.
formatColorFormatControlled output format.
defaultFormatColorFormat"p3"Uncontrolled initial format.
onFormatChange(format) => voidFires on format toggle.
formatsColorFormat[]all 7 formatsRestricts which output formats the picker exposes — both the FormatSwitcher options and the resolved default.
backgroundColorstring | OklchColor#fffBackground used for contrast metrics and Preview compositing.

ColorFormat = "hex" | "rgb" | "hsl" | "hsb" | "oklch" | "oklab" | "p3".

API: Parts

PropTypeDefaultDescription
<ColorPicker.Area>mode, chromaMax, gamut, showWarningLines, resolution, softProofmode picks the axes: oklch-cl (Y = OKLCH lightness, top row is white), hsv-sv (Y = HSV-style "value", top-left = white, top-right = saturated), oklch-hc (X = hue, Y = chroma — pair with ColorPicker.Lightness). gamut controls the render gamut and warning lines: "srgb", "p3", "rec2020", "none". Defaults to the gamut implied by the active output format. showWarningLines (default true) toggles the cutoff lines without changing the render gamut. softProof (default false) chroma-reduces out-of-display colors in OKLCH instead of letting srgbEncode per-channel-clip them — hue and lightness stay true past the display gamut at the cost of a flatter chroma boundary. Useful when render gamut exceeds the user's display (e.g. rec2020 on a P3 monitor) and you'd rather see a hue-faithful soft proof than a hue-shifted clip. Keyboard: arrows ±1%, Shift+arrows ±10%, Home/End, PageUp/Down.
<ColorPicker.Hue>orientationHue slider. orientation = "horizontal" | "vertical". Pair with Area mode "oklch-cl" or "hsv-sv". Mode-aware: when format is "hsl" or "hsb" the slider tracks that format's hue scale (so the bead matches the channel input H exactly); for OKLCH/OKLab it tracks canonical OKLCH hue with chroma rescaling on commit. Hex/RGB/P3 fall back to OKLCH hue.
<ColorPicker.Lightness>orientationLightness slider (OKLCH `l` 0→1). Gradient is sampled at the current hue+chroma. Pair with Area mode "oklch-hc".
<ColorPicker.Chroma>orientationChroma slider (OKLCH `c` 0→0.4). Track is painted at the current hue × lightness so the ramp shows how chroma changes the user's color (not a generic gray→vivid). Keyboard: arrows ±0.005, Shift ±0.05, Home/End, PageUp/Down ±0.05.
<ColorPicker.Alpha>Opacity slider with checkerboard background.
<ColorPicker.Preview>40px swatch composited over backgroundColor.
<ColorPicker.CssInput>Single text input that parses any CSS Color 4 string on Enter/blur. Marks invalid via aria-invalid; Escape reverts.
<ColorPicker.FormatSwitcher>formatsNative <select> of formats. Reads the available list from <ColorPicker.Root formats={...}>; pass an explicit `formats` prop to override locally.
<ColorPicker.ChannelInput>formats, showFormatshowFormat=truePhotoshop-style multi-field input. Renders the format selector + one numeric field per channel (R/G/B/A%, H/S/L/A%, etc.) plus an alpha % field. For "hex" falls back to a single text field. Pass showFormat={false} when pairing with a standalone <FormatSwitcher /> elsewhere in the layout (skips the inline selector). Each numeric field supports ↑/↓ to step (Shift = big step) and accepts a pasted CSS color string from any field.
<ColorPicker.Swatches>presets, onAddGrid of preset chips. presets accepts any CSS color strings (including wide-gamut color(display-p3 …) — they paint in their native gamut on capable displays). When onAdd is provided, renders a “+” tile after the presets that calls onAdd(color, hex); the consumer owns persistence (lift presets and update on add — localStorage / a server / a store / etc.).
<ColorPicker.GamutBadge>showLabelshowLabel=trueLive status: sRGB / P3 / Rec.2020 / Out of gamut. Hovering shows a shadcn Tooltip with the active color space. Set showLabel={false} to drop the "Gamut" prefix and render just the space name (good for cramped layouts paired with ContrastReadout in the same row).
<ColorPicker.ContrastReadout>metrics, defaultMetric, showLabel, showValue, showBadgesSurfaces one contrast metric at a time. metrics is ("wcag" | "apca")[] (default ["wcag"]); when metrics.length > 1 the readout becomes a button — click to cycle, with a tooltip on the ⇅ icon that names the next metric. Toggle showLabel / showValue / showBadges (all default true) to hide the metric name, the numeric value, or the AA/AAA / body/headline / fail pills — set everything but showBadges to false for a minimal pass/fail-only badge. Each pass/fail badge has its own hover tooltip explaining the threshold (e.g. "Passes WCAG AA — body text needs ≥ 4.5:1").
<ColorPicker.EyeDropper>Native EyeDropper API. Renders nothing on unsupported browsers.

API: Gradient parts

Every part inherits standard HTMLAttributes<HTMLDivElement> (className, data attrs, etc.) on top of the props listed. All must render inside <GradientPicker.Root> (or <FP.Pane mode="gradient">) — they throw otherwise.

PropTypeDefaultDescription
<GradientPicker.Root>value, defaultValue, onValueChange, defaultStopColorFormatdefaultStopColorFormat: "oklch"Controlled or uncontrolled gradient state. `onValueChange(gradient, css)` fires with the canonical Gradient object and the pre-serialized CSS string. `defaultStopColorFormat` (default `oklch`) seeds the per-stop display format used by every StopList row and StopColor FormatSwitcher.
<GradientPicker.TypeSwitcher>Dropdown to swap linear / radial / conic. Preserves stops and interpolation across the switch.
<GradientPicker.ReverseStops>Icon button. Mirrors every stop position around 0.5 — visual order flips while ids stay attached to their colors. Hint positions flip too.
<GradientPicker.RepeatingToggle>offIcon toggle. When on, emits `repeating-<type>-gradient(…)` — the stop ramp tiles instead of stretching to fill the box.
<GradientPicker.Bar>height, handleSize, editOnClickheight: 12, handleSize: 16, editOnClick: falseHorizontal stop strip — drag handles to reposition, drag below the bar (~24px) to remove. With `editOnClick`, a tap-without-drag opens the same stop-color editor popover that StopList uses (movement-based detection: flicking a handle a pixel counts as a drag, not a tap).
<GradientPicker.Area>Visual 2D pad — paints the live gradient and overlays draggable handles for direction (linear endpoints), center (radial/conic), or shape size. Pair with Bar for the canonical layout.
<GradientPicker.Overlay>Same handles as Area, but transparent — drop inside any consumer-rendered canvas (e.g. a preview frame) and the handles align to that element's box. The canvas paints the gradient itself; Overlay only contributes interaction.
<GradientPicker.StopList>showAddStopshowAddStop: trueKeyboard-driven listbox of stops. Each row: swatch (click → full stop-color popover bound to that stop), numeric % position with ↑/↓ nudge, inline CSS-color paste field, remove button. Trailing `+ Add stop` inserts halfway to the next neighbor (or to the previous neighbor when the selected stop is last), sampling the existing ramp.
<GradientPicker.StopColor>(children)Mounts ColorPickerContext bound to the selected stop. Drop any `<ColorPicker.*>` parts inside to build a per-stop editor surface (Area, Hue, Chroma, Lightness, ChannelInput, FormatSwitcher, …).
<GradientPicker.AngleGroup>(children)Wrapper for <AnglePad /> + <AngleInput />. Sets row layout + width split.
<GradientPicker.AnglePad>Circular dial for linear `angle` / conic `startAngle`. Drag the handle to rotate; arrow keys ±1°, Shift ±15°, Home / End.
<GradientPicker.AngleInput>Numeric ° field paired with AnglePad. Accepts any number; wraps to 0..360. ↑/↓ nudge by 1, Shift × 10.
<GradientPicker.PositionGroup>(children)Wrapper for <PositionPad /> + <PositionInput /> (+ <RadiusInput /> / <EllipseRadiiInput /> for radials).
<GradientPicker.PositionPad>2D pad for radial / conic `center` (0..1 fractions). Click or drag to position.
<GradientPicker.PositionInput>Numeric x% / y% fields paired with PositionPad. ↑/↓ nudge by 1, Shift × 10.
<GradientPicker.ShapeSwitcher>Radial-only — toggles between `circle` (absolute px radius) and `ellipse` (x/y percentage radii). Each shape's last numeric override is stashed so toggling back restores it.
<GradientPicker.RadialSizeSelect>Radial-only — picks the size keyword (closest-side, closest-corner, farthest-side, farthest-corner). Mutually exclusive with RadiusInput / EllipseRadiiInput overrides.
<GradientPicker.RadiusInput>Circle-only numeric radius input. Reports px when no Area is mounted; switches to % once Area observes its container width. Empty / placeholder reverts to the active size keyword.
<GradientPicker.EllipseRadiiInput>Ellipse-only x/y radii (% of container box). Empty reverts to the active size keyword.
<GradientPicker.InterpSwitcher>interp: "oklch"Native <select> bound to `gradient.interp` (oklch / oklab / srgb / hsl / hsl-longer). Emits the matching CSS Color 4 `in <space>` clause.
<GradientPicker.Presets>presets, onAddStrip of preview tiles. Click to replace the active gradient. `onAdd(gradient, css)` (optional) wires up a trailing + tile that captures the current gradient — pair with consumer state for save-to-saved-list flows.
<GradientPicker.CssInput>Single text field — paste any CSS gradient string to replace the active gradient. Reverts to the last good value on parse failure.

API: useColorPicker hook

Headless layer powering every part. Use it directly when you want a totally custom UI but the same state machine.

const {
  color,           // canonical OklchColor
  format,
  formatted,       // string in 'format'
  formats,         // ColorFormat[] — the list of allowed output formats
  formatStrings,   // Record<ColorFormat, string> — every format pre-serialized
  gamut,           // GamutInfo
  contrast,        // { wcag, wcagLevel, apca }
  setColor,        // accepts string | OklchColor
  setComponent,    // ('l'|'c'|'h'|'alpha', value) — clamped
  adjustComponent, // ('l'|'c'|'h'|'alpha', delta) — wraps for hue
  setFormat,
  setFromString,   // (s) => boolean; false on parse failure
  background,
} = useColorPicker({
  defaultValue: "#ff0000",
  backgroundColor: "#fff",
  formats: ["hex", "oklch", "p3"], // optional; defaults to all
});

API: Color utilities

Exported from the same module:

import {
  parseColor,    // (string) => OklchColor | null
  formatColor,   // (OklchColor, ColorFormat) => string  (sRGB/P3 outputs are gamut-mapped)
  formatAll,     // (OklchColor) => Record<ColorFormat, string>
  gamutInfo,     // (OklchColor) => { inSrgb, inP3, inRec2020 }
  toGamut,       // (OklchColor, "srgb"|"p3"|"rec2020") => OklchColor
  contrast,      // (fg, bg) => { wcag, wcagLevel, apca }
  apcaContrast,  // (fg, bg) => Lc number
  isValidColor,  // (string) => boolean
} from "@/components/ui/fill-picker/color-picker";

API: Types

Every type the picker exports, in one place. All are exported from the same barrel as the components, so you can type your own state against them rather than re-declaring shapes. The gradient union itself is broken out under Output: state shape + CSS.

// ---- Color ----------------------------------------------------------------

/** Canonical state. Every format is a lossless projection of this. */
interface OklchColor {
  l: number;      // 0..1 perceptual lightness
  c: number;      // chroma, unbounded above (not clamped to a display gamut)
  h: number;      // 0..360 degrees
  alpha: number;  // 0..1
}

type ColorFormat =
  | "hex" | "rgb" | "hsl" | "hsb"   // sRGB-targeted, gamut-mapped on output
  | "oklch" | "oklab"               // unbounded, lossless
  | "p3";                           // color(display-p3 …)

type Gamut = "srgb" | "p3" | "rec2020";

interface GamutInfo {
  inSrgb: boolean;
  inP3: boolean;
  inRec2020: boolean;
}

interface ContrastResult {
  wcag: number;        // WCAG 2.1 ratio, 1..21
  wcagLevel: { aaNormal: boolean; aaLarge: boolean; aaaNormal: boolean; aaaLarge: boolean };
  apca: number;        // APCA Lc, signed: negative = light text on dark
}

// ---- Gradient ---------------------------------------------------------------

type GradientType = "linear" | "radial" | "conic";

type GradientInterp = "oklch" | "oklab" | "srgb" | "hsl" | "hsl-longer";

type RadialSizeKeyword =
  | "closest-side" | "closest-corner"
  | "farthest-side" | "farthest-corner";

// ---- Fill (what <FillPicker> emits) -----------------------------------------

type ColorFill    = { kind: "color";    color: OklchColor };
type GradientFill = { kind: "gradient"; gradient: Gradient };
type Fill = ColorFill | GradientFill;

Color spaces

sRGB

The baseline web gamut. Every device made in the last 30 years can render it. #hex, rgb(), hsl() all live here.

Display-P3

Apple's wide-gamut space. Every recent iPhone, iPad, and MacBook supports it; many newer Android phones too. About 25% wider than sRGB, especially in reds and greens. Authored as color(display-p3 r g b).

OKLCH

Perceptually uniform polar space. The same chroma value looks equally vivid across all hues; the same lightness looks equally bright. This is why all picker state is stored here — sliders feel intuitive and conversions don't drift. CSS Color 4: oklch(L C H).

OKLab

Same color space as OKLCH but in cartesian (a/b) form. Good for color-difference math, less good for UIs.

When you author a P3 or wider color and the user's display can't render it, the browser falls back. The <GamutBadge> and the warning lines on <Area> keep you informed. The Area always fills with in-gamut color for the active render gamut; warning lines mark the cutoffs of narrower gamuts inside the fill. Pick hex/rgb/hsl/hsb and the area fills with sRGB only — no warning line. Pick p3 and a single thin line marks the sRGB cutoff inside the P3 fill. Pick oklch/oklab and two thin lines mark the sRGB and P3 cutoffs inside the Rec.2020 fill.

Accessibility

  • Keyboard. Every interactive part is reachable via Tab. Sliders follow the WAI-ARIA APG slider pattern (arrow keys ±1, Shift ±10, Home/End, PageUp/Down). The 2D Area uses role="application" with aria-roledescription and aria-valuetext describing the current point.
  • Pointer + touch. Pointer capture so drags don't escape; touch-none to suppress browser scroll while interacting.
  • Focus. Visible focus ring on all controls via focus-visible:ring.
  • Color independence. Gamut and contrast information is conveyed via text + ARIA, not color alone.
  • Reduced motion. No auto-animation; inherits user preference for swatch hover scale.