Color Editor
ColorEditor is the full, inline color-editing panel — a saturation/brightness area, a hue slider, an optional alpha slider, a row of channel scrubbers, a hex input, and an optional row of swatches. Turn on gradient and it gains a Solid/Gradient switch plus two-stop linear-gradient editing with an angle control. It's the surface you embed in an inspector, a theme editor, or a design-tool sidebar when picking a color is the task and you want it always visible.
For the popover version — a trigger swatch that opens this same panel on demand — reach for ColorPicker instead. ColorEditor is the panel; ColorPicker wraps it in a trigger.
<div className="w-56">
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
swatches={PALETTE}
/>
</div>Usage
import { ColorEditor } from '@cladd-ui/react';
const [color, setColor] = useState('#2f6bff');
<ColorEditor value={color} onChange={(c) => setColor(c.hex)} />;The panel is full-width — it has no intrinsic width of its own and stretches to fill its container. Wrap it in a fixed-width element (or drop it into an inspector column) to size it. value accepts any CSS color string or a single channel set ({ r, g, b }, { h, s, l }, { h, s, b }); onChange fires on every change with a fully-resolved value carrying hex, rgb, hsl, hsb, and css — read whichever you need straight off it. Leave value off and pass defaultValue to run it uncontrolled. See Values below for the full breakdown of what goes in and what comes out.
Internally the panel is built from real cladd primitives — the channel scrubbers are NumberScrubber, the hex field is an Input, the Solid/Gradient switch is a Toolbar — so it inherits the surface and accent of its surroundings. Drop it inside a Surface and its focus rings and active states pick up that surface's color.
Values
The editor is permissive on the way in and exhaustive on the way out: value / defaultValue accept a handful of loose formats, but onChange always hands back one fully-resolved object carrying every representation. You never have to convert before passing a color in, and you never have to convert after reading one out — just store the field you care about.
Accepted input
value and defaultValue take a ColorInput — either a CSS color string or a single channel set. Alpha is optional on every form; omit it for fully opaque.
| Form | Examples | Ranges |
|---|---|---|
| Hex string | '#2f6bff', '#2f6bffcc', '#fff', '#fff8' | 3, 4, 6, or 8 digits; # required |
| CSS function string | 'rgb(47 107 255)', 'rgba(47,107,255,0.8)', 'hsl(220 100% 59%)' | modern (space) or legacy (comma) form |
| Named color | 'red', 'white', 'transparent' | basic set only — see note |
| RGB object | { r: 47, g: 107, b: 255 } | r, g, b 0–255 · a 0–1 |
| HSL object | { h: 220, s: 100, l: 59 } | h 0–360 · s, l 0–100 · a 0–1 |
| HSB object | { h: 220, s: 82, b: 100 } | h 0–360 · s, b 0–100 · a 0–1 |
A few things worth knowing:
- Alpha is always
0–1on channel sets, even though hex encodes it as two extra digits (#rrggbb**aa**). Leaveaoff and the color is opaque. - Out-of-range numbers are clamped, and an unparseable string falls back to black (
#000000) rather than throwing — so a typo'd value silently becomes black. - Named colors cover only a basic set —
transparent,black,white,gray/grey,silver,red,maroon,orange,yellow,olive,lime,green,aqua/cyan,teal,blue,navy,fuchsia/magenta,purple,pink. Anything outside it (coral,rebeccapurple, …) is not recognized and falls back to black. Use hex/rgb/hsl for those. - Input is independent of
format. You can pass{ h, s, l }and still display RGB scrubbers —formatonly controls which channels the scrubber row shows, never how you supply or receive the value.
Emitted value
onChange fires on every change — area drag, hue/alpha slide, channel scrub, hex commit, swatch click — with a single ColorValue. It always carries every representation regardless of what you passed in or which format is on screen, so read whichever field fits:
| Field | Type | Notes |
|---|---|---|
hex | string | #rrggbb, or #rrggbbaa when alpha < 1 |
rgb | { r, g, b, a } | r, g, b 0–255 · a 0–1 |
hsl | { h, s, l, a } | h 0–360 · s, l 0–100 · a 0–1 |
hsb | { h, s, b, a } | h 0–360 · s, b 0–100 · a 0–1 |
css | string | #rrggbb when opaque, else rgba(...); ready for background |
<ColorEditor
value={color}
onChange={(c) => {
c.hex; // '#2f6bff'
c.rgb; // { r: 47, g: 107, b: 255, a: 1 }
c.hsl; // { h: 220, s: 100, l: 59, a: 1 }
c.css; // '#2f6bff' (or 'rgba(47, 107, 255, 0.8)' when translucent)
setColor(c.hex);
}}
/>To run it controlled, store one field and feed it back to value — the emitted object as a whole is not a valid input. c.hex round-trips cleanly for solids; c.css round-trips for both solids and gradients, which makes it the simplest choice once gradients are in play.
Gradient values
With gradient on, both ends widen. The Solid mode still accepts (and emits) the solid forms above; the Gradient mode adds these inputs:
linear-gradient(...)string —'linear-gradient(90deg, #2f6bff 0%, #ec4899 100%)'. Direction keywords work too (to right,to bottom left) and are converted to degrees; an omitted angle defaults to90deg.- Gradient object —
{ angle: 90, stops: [{ color, position }] }.typeis optional ('linear'),angledefaults to90, eachposition(0–100) defaults to an even spread, and each stopcoloris itself anyColorInput— so channel-set stops work without building a string:
// Equivalent gradient inputs — pass whichever form your data is already in:
value = 'linear-gradient(90deg, #2f6bff 0%, #ec4899 100%)';
value = {
angle: 90,
stops: [
{ color: { r: 47, g: 107, b: 255, a: 1 }, position: 0 },
{ color: { r: 236, g: 72, b: 153, a: 1 }, position: 100 },
],
};The editor edits exactly two stops — a gradient input with more keeps only the first and last.
onChange then emits a discriminated ColorEditorValue. Branch on type:
// Solid mode — the full ColorValue, tagged:
{ type: 'solid', hex, rgb, hsl, hsb, css }
// Gradient mode:
{
type: 'linear',
angle: 90, // degrees, 0–360
stops: [ // always two, sorted by position
{ color: ColorValue, position: 0 },
{ color: ColorValue, position: 100 },
],
css: 'linear-gradient(90deg, …)', // ready for style={{ background }}
}Each stop's color is a full ColorValue (c.stops[0].color.hex, etc.). c.css is a ready background string in both modes, so storing it is the one-liner that handles solid and gradient alike:
const [css, setCss] = useState(
'linear-gradient(90deg, #2f6bff 0%, #ec4899 100%)',
);
<ColorEditor gradient value={css} onChange={(c) => setCss(c.css)} />;Examples
Overview
A solid editor with the full control stack and a row of preset swatches. Clicking a swatch applies it to the color; swatches are solid colors only. The value is held in state and read back via c.hex in onChange.
<div className="w-56">
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
swatches={PALETTE}
/>
</div>Channel format
format picks which channels the scrubber row shows — 'rgb' (default), 'hsl', or 'hsb'. It only changes the scrubber labels and the numbers the user edits; the emitted value always carries every representation (rgb, hsl, hsb, hex, css) regardless of which format is on screen.
<div className="w-56">
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
format="rgb"
/>
</div>Gradient
Pass gradient to enable the Solid/Gradient switch and two-stop linear-gradient editing. In gradient mode the panel gains a stop bar (drag the two thumbs, click to select which stop you're editing), a flip button, and an angle control. value/defaultValue accept a linear-gradient() CSS string or a gradient object, and onChange fires a discriminated value: { type: 'solid', ...ColorValue } or { type: 'linear', angle, stops, css }. Read c.css to get a ready-to-use background string for either mode.
<div className="w-64">
<ColorEditor
gradient
value={css}
onChange={(c: ColorEditorValue) => setCss(c.css)}
swatches={PALETTE}
/>
</div>Gradient from object stops
A linear-gradient() string isn't the only way to supply a gradient. Pass a { angle, stops } object and each stop's color is any ColorInput — including an { r, g, b, a } (or { h, s, l }) channel set, so you never have to assemble a CSS string by hand. The emitted stops come back the same shape with a full ColorValue per stop, so read the rgba straight off c.stops[i].color.rgb to keep your state in channel form. Positions are 0–100; angle is in degrees and defaults to 90.
const [angle, setAngle] = useState(90);
const [stops, setStops] = useState([
{ color: { r: 47, g: 107, b: 255, a: 1 }, position: 0 },
{ color: { r: 236, g: 72, b: 153, a: 1 }, position: 100 },
]);
// Pass a { angle, stops } object — each stop color is any ColorInput, here an
// { r, g, b, a } channel set. Every emitted stop carries a full ColorValue, so
// read the rgba straight back off .color.rgb.
<ColorEditor
gradient
value={{ angle, stops }}
onChange={(c) => {
if (c.type !== 'linear') return;
setAngle(c.angle);
setStops(
c.stops.map((s) => ({ color: s.color.rgb, position: s.position })),
);
}}
/>;Angle as a button
angleControl switches the gradient angle widget between a degree 'scrubber' (default) and a 'button' that steps the angle 45° per click. The button is the denser choice when you want the gradient bar to take the row and only need coarse direction control.
<div className="w-64">
<ColorEditor
gradient
angleControl="button"
value={css}
onChange={(c: ColorEditorValue) => setCss(c.css)}
/>
</div>Compact (area only)
Strip the panel down to just the saturation/brightness area and hue slider by turning off alpha, inputs, and hexInput, and shrink the area with areaClassName. Dropped inside a Surface with a color, the picker inherits that accent for its focus ring — useful for a tiny inline picker that lives in a denser inspector row.
<Surface outline className="w-56 rounded-3xl" contentClassName="p-4">
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
alpha={false}
inputs={false}
hexInput={false}
areaClassName="h-24"
/>
</Surface>Control size
controlSize scales the inner controls — scrubbers, hex input, gradient buttons — across sm, md (default), lg, xl, and 2xl. The Solid/Gradient toolbar always renders one step smaller than the rest. The saturation/brightness area and the slider bars keep their fixed height; size them separately via areaClassName and className if you need to.
<div className="w-64">
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
controlSize="md"
/>
</div>Control outline
controlOutline toggles the surface outline on the inner controls — the channel and alpha scrubbers, the hex input, and the Solid/Gradient toolbar. On by default; turn it off for a flatter, more recessed panel where the controls read as part of the surface rather than as outlined fields. The gradient bar's flip and angle controls stay ghost either way.
<div className="w-64">
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
controlOutline
/>
</div>Header & footer
header and footer take any React node and render above the controls and below the swatches, inside the panel. Use them for the chrome an inspector needs around the picker — a SectionTitle and a live hex Chip up top, an Apply Button at the bottom. Both slots stay interactive when the panel is disabled, so they're also the place for an inherit toggle or eyedropper that should keep working while the color controls are dimmed.
<Surface outline className="w-64 rounded-3xl" contentClassName="p-4">
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
swatches={PALETTE}
header={
<div className="flex items-center justify-between gap-2">
<SectionTitle>Fill</SectionTitle>
<Chip
size="md"
className="font-mono font-medium"
contentClassName="text-xs"
>
{color}
</Chip>
</div>
}
footer={
<Button color="brand" size="md" rounded className="mt-2 w-full">
Apply fill
</Button>
}
/>
</Surface>Playground
controlSize, format, gradient, and the alpha / inputs / hexInput toggles compose freely; the surrounding Surface color tints the panel's accent. The hue and alpha bars are the same kind of track you'll find in Slider, and the channel row is a NumberScrubber per channel.
<Surface
outline
color={accent}
className="w-72 rounded-3xl"
contentClassName="p-4"
>
{gradient ? (
<ColorEditor
gradient
value={css}
onChange={(c: ColorEditorValue) => setCss(c.css)}
controlSize="md"
format="rgb"
alpha
inputs
hexInput
controlOutline
swatches={swatches ? PALETTE : undefined}
/>
) : (
<ColorEditor
value={color}
onChange={(c: ColorValue) => setColor(c.hex)}
controlSize="md"
format="rgb"
alpha
inputs
hexInput
controlOutline
swatches={swatches ? PALETTE : undefined}
/>
)}
</Surface>API Reference
ColorEditorProps is a discriminated union on gradient. With gradient omitted or false, value/defaultValue take a solid ColorInput and onChange fires a ColorValue. With gradient set to true, value/defaultValue additionally accept a GradientInput (a linear-gradient() string or gradient object) and onChange fires a discriminated ColorEditorValue — { type: 'solid', ...ColorValue } or { type: 'linear', angle, stops, css }. ColorEditorControlSize is 'sm' | 'md' | 'lg' | 'xl' | '2xl' and ColorEditorFormat is 'rgb' | 'hsl' | 'hsb'.
| Name: Type | Default | Description |
|---|---|---|
| alpha: boolean | true | Show the alpha slider and the alpha scrubber. Default true. |
| angleControl: 'button' | 'scrubber' | 'scrubber' | Angle control in gradient mode: a 45°-step button, or a degree scrubber. Default 'scrubber'. |
| areaClassName: string | — | Extra classes for the saturation/brightness area (e.g. to set its height). |
| className: string | — | Extra classes for the panel root. The panel is full-width — size it via its container. |
| controlOutline: boolean | true | Render the surface outline on the inner controls — channel/alpha scrubbers, hex input, and the Solid/Gradient toolbar. The gradient bar's flip/angle controls stay ghost regardless. Default true. |
| controlSize: ColorEditorControlSize | 'md' | Size of the inner controls — scrubbers, hex input, gradient buttons. Default 'md'.The Solid/Gradient toolbar is rendered one step smaller. |
| debounce: number | 0 | Debounce onChange calls in ms. Fires once after changes stop for N ms. Defaults to 0 (immediate). |
| defaultValue: ColorInput | — | Initial value (uncontrolled). |
| disabled: boolean | — | Dim the panel and block interaction. |
| footer: React.ReactNode | — | Content rendered below the panel, after the swatches. Stays interactive when disabled. |
| format: ColorEditorFormat | 'rgb' | Which channels the scrubber row shows. Default 'rgb'. |
| gradient: false | false | Enable the Solid/Gradient switch and gradient editing. Default false. |
| header: React.ReactNode | — | Content rendered above the panel, before the controls. Stays interactive when disabled (only the panel below dims).For inherit toggles, titles, eyedroppers, etc. |
| hexInput: boolean | true | Show the hex input. Default true. |
| inputs: boolean | true | Show the channel-scrubber row. Default true. |
| onChange: (value: ColorValue) => void | — | Fires on every change with the full color. |
| readOnly: boolean | — | Block interaction without the dimmed treatment. |
| swatches: ColorInput[] | — | Preset colors rendered as a row of thumbs. Clicking one applies it to the color (or the selected gradient stop). Solid colors only. |
| throttle: number | 0 | Throttle onChange calls in ms. Fires immediately, then at most once per N ms while changing, with a trailing call for the final value. Defaults to 0 (immediate).Takes precedence over debounce. |
| value: ColorInput | — | Controlled value. A CSS color string or any one channel set. |