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.
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.jsonGradient 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.jsonFill 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.jsonInstalls 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.
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.
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.
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 settinggradient.startandgradient.end— endpoints can then sit anywhere inside the box, and the line follows them. CSSlinear-gradientcannot represent an offset line natively, so on emit the angle is derived fromend − startand stop positions are re-mapped into the projected segment, giving the visual offset for free in pure CSS.parseGradientcannot recoverstart/endfrom 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.centerplus an edge handle that drivesgradient.radii— an optional{ x, y }pair (fractions of box width / height) that overrides the keywordshape+sizeform at emit time. Forshape: "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 stayundefinedand 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| "closest-side" | closest-side | — | Ellipse / circle ends at the nearest box edge. Smallest, snuggest gradient — the inner colors get the most room. |
| "closest-corner" | closest-corner | — | Ends 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-side | — | Ends at the farthest edge of the box. Bigger than closest-corner but smaller than farthest-corner. |
| "farthest-corner" | farthest-corner | default | Ends 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:
| Prop | Type | Default | Description |
|---|---|---|---|
| "oklch" | in oklch | default | Perceptually 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 oklab | — | Perceptually 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 srgb | — | Per-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 hsl | — | HSL 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 hue | — | HSL 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.
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>
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | OklchColor | — | Controlled 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. |
| defaultValue | string | OklchColor | — | Uncontrolled initial value. |
| onValueChange | (color, formatted, formats) => void | — | Fires 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. |
| format | ColorFormat | — | Controlled output format. |
| defaultFormat | ColorFormat | "p3" | Uncontrolled initial format. |
| onFormatChange | (format) => void | — | Fires on format toggle. |
| formats | ColorFormat[] | all 7 formats | Restricts which output formats the picker exposes — both the FormatSwitcher options and the resolved default. |
| backgroundColor | string | OklchColor | #fff | Background used for contrast metrics and Preview compositing. |
ColorFormat = "hex" | "rgb" | "hsl" | "hsb" | "oklch" | "oklab" | "p3".
API: Parts
| Prop | Type | Default | Description |
|---|---|---|---|
| <ColorPicker.Area> | mode, chromaMax, gamut, showWarningLines, resolution, softProof | — | mode 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> | orientation | — | Hue 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> | orientation | — | Lightness slider (OKLCH `l` 0→1). Gradient is sampled at the current hue+chroma. Pair with Area mode "oklch-hc". |
| <ColorPicker.Chroma> | orientation | — | Chroma 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> | formats | — | Native <select> of formats. Reads the available list from <ColorPicker.Root formats={...}>; pass an explicit `formats` prop to override locally. |
| <ColorPicker.ChannelInput> | formats, showFormat | showFormat=true | Photoshop-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, onAdd | — | Grid 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> | showLabel | showLabel=true | Live 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, showBadges | — | Surfaces 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| <GradientPicker.Root> | value, defaultValue, onValueChange, defaultStopColorFormat | defaultStopColorFormat: "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> | — | off | Icon toggle. When on, emits `repeating-<type>-gradient(…)` — the stop ramp tiles instead of stretching to fill the box. |
| <GradientPicker.Bar> | height, handleSize, editOnClick | height: 12, handleSize: 16, editOnClick: false | Horizontal 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> | showAddStop | showAddStop: true | Keyboard-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, onAdd | — | Strip 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"witharia-roledescriptionandaria-valuetextdescribing the current point. - Pointer + touch. Pointer capture so drags don't escape;
touch-noneto 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.