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.

FormExamplesRanges
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–1 on channel sets, even though hex encodes it as two extra digits (#rrggbb**aa**). Leave a off 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 settransparent, 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 — format only 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:

FieldTypeNotes
hexstring#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
cssstring#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 to 90deg.
  • Gradient object{ angle: 90, stops: [{ color, position }] }. type is optional ('linear'), angle defaults to 90, each position (0–100) defaults to an even spread, and each stop color is itself any ColorInput — 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 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.

Fill
#7c3aed
#
<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'.

alpha: booleantrueShow 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: stringExtra classes for the saturation/brightness area (e.g. to set its height).
className: stringExtra classes for the panel root. The panel is full-width — size it via its container.
controlOutline: booleantrueRender 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: number0Debounce onChange calls in ms. Fires once after changes stop for N ms. Defaults to 0 (immediate).
defaultValue: ColorInputInitial value (uncontrolled).
disabled: booleanDim the panel and block interaction.
footer: React.ReactNodeContent rendered below the panel, after the swatches. Stays interactive when disabled.
format: ColorEditorFormat'rgb'Which channels the scrubber row shows. Default 'rgb'.
gradient: falsefalseEnable the Solid/Gradient switch and gradient editing. Default false.
header: React.ReactNodeContent rendered above the panel, before the controls.

Stays interactive when disabled (only the panel below dims).

For inherit toggles, titles, eyedroppers, etc.
hexInput: booleantrueShow the hex input. Default true.
inputs: booleantrueShow the channel-scrubber row. Default true.
onChange: (value: ColorValue) => voidFires on every change with the full color.
readOnly: booleanBlock 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: number0Throttle 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: ColorInputControlled value. A CSS color string or any one channel set.