| `Label`| Single label. Clicking focuses the first segment. |
| `FormatSelect`| Native `<select>` that switches `format`. When `allowedFormats.length === 1` it renders as a read-only `<span>` (same `data-*` contract + `data-locked`) — no interactive element, the format is just labelled. |
| `Input`| `role="group"` container. Exposes `segments` via snippet props. |
| `Segment`| One segment — `r / g / b / h / s / l / alpha / literal`. Numeric channels use `role="spinbutton"`. |
| `HiddenInput`| Rendered automatically by `Input` when `name` is set. Value is the canonical formatted string (`format` determines shape). |
| `Label` | Single label. Clicking focuses the first segment. |
| `FormatSelect` | Native `<select>` that switches `format`. When `allowedFormats.length === 1` it renders as a read-only `<span>` (same `data-*` contract + `data-locked`) — no interactive element, the format is just labelled. |
| `custom` | `validate(ColorValue)` returned a message. |
| `incomplete` | Reserved for future use (e.g. when `required=true` + unfilled segments). |
An unfilled segment is **not** invalid on its own — it keeps `value=undefined` until all required channels commit, mirroring DateField / TimeField semantics.
| `segments` | `ColorSegmentContentObj[]` | Ordered segments for the active format + alpha (if on). |
Each element: `{ part: ColorSegmentPart; value: string }` where `value` is the display string (formatted channel value, or placeholder like `'rr'` / `'hhh'` when unset, or a literal like `','` / `'%'`).
| Parent Field integration | via Form | ✗ | ✗ | ✓ (soma Field) |
| Anatomy | 2 parts (Label, Input) | 3 parts | 3 parts | 6 parts |
No mainstream headless library packs all channels into a single segmented field with a built-in format switcher — Ark / Zag split it into `ChannelInput` instances the consumer composes manually, React Aria ships one `ColorField` per channel. Soma unifies them and adds the `FormatSelect` + `allowedFormats` lock.
@ -191,5 +191,5 @@ No mainstream headless library packs all channels into a single segmented field
## Related
- [`ColorPicker`](../color-picker/README.md) — visual picker with area + sliders + swatches (planned). Will compose `ColorField` as its `ChannelInput` equivalent.
- [`ColorRangeField`](../color-range-field/README.md) — paired `ColorField`s for gradient ranges (planned).
- [`ColorPicker`](../color-picker/README.md) — visual picker with area + sliders + swatches. It composes `ColorField` through `ChannelInput`.
- `ColorRangeField` — paired `ColorField`s for gradient ranges (planned).
| Consumer changes `format` to HEX externally while popover is open | Trigger = HEX, popover = HEX (`watch.pre` resync) | Same |
| User closes popover and reopens it | popover stays HSL (internal format persists across open/close) | popover stays at whatever external is now |
| `onFormatChange` callback | Fires only on external changes | Fires on popover changes too (propagated) |
### When to use `autoFormat`
- **Default (`autoFormat=false`)** fits most cases where the consumer wants a stable canonical format (e.g. always submit `#rrggbb` to the backend) and the popover is just a rich editor that shouldn't drift that contract.
- **`autoFormat=true`** is the right mode when the consumer wants the user's *last picked* format to survive — e.g. a design-tool-like experience where opening the popover and switching to HSL should stick after closing.
- **`autoFormat=true`** is the right mode when the consumer wants the user's _last picked_ format to survive — e.g. a design-tool-like experience where opening the popover and switching to HSL should stick after closing.
If you want the *popover* format to reset to the external one every time the popover opens (a third flavour), override it by writing the external `format` to the popover state on `onOpenChange(true)` — soma intentionally does not do this automatically because many apps prefer the persistence.
If you want the _popover_ format to reset to the external one every time the popover opens (a third flavour), override it by writing the external `format` to the popover state on `onOpenChange(true)` — soma intentionally does not do this automatically because many apps prefer the persistence.
## Channels
@ -152,13 +154,13 @@ If you want the *popover* format to reset to the external one every time the pop
The `ChannelSlider` takes `channel` as a required prop. Supported channel ranges:
| Channel | Min | Max | Unit |
| ------------ | --- | --- | ------- |
| `hue` | 0 | 360 | degrees |
| `saturation` | 0 | 100 | % |
| `brightness` | 0 | 100 | % |
| `red / green / blue` | 0 | 255 | 0-255 |
| `alpha` | 0 | 1 | 0-1 |
| Channel | Min | Max | Unit |
| -------------------- | --- | --- | ------- |
| `hue`| 0 | 360 | degrees |
| `saturation`| 0 | 100 | % |
| `brightness`| 0 | 100 | % |
| `red / green / blue` | 0 | 255 | 0-255 |
| `alpha`| 0 | 1 | 0-1 |
The 2D `Area` by default maps X = `saturation`, Y = `brightness`. Override via the Provider's `areaChannels` prop (soma innovation — React Aria inspired).
@ -187,41 +189,41 @@ The 2D `Area` by default maps X = `saturation`, Y = `brightness`. Override via t
All parts emit their canonical `data-color-picker-{part}` attribute. State-bearing attributes:
| `locale / dir / errorMessageId`| — | Standard. |
### Field integration
@ -229,33 +231,33 @@ Wrapping in `Field.Provider` inherits its `disabled`/`readonly`/`required`/`inva
## Comparison with reference libraries
> **Format-independence is unique to soma.** Every other mainstream headless picker ties the `format` prop to a *single* reactive state — changing format anywhere inside the picker immediately flips what the `value` text / hidden input / callbacks report. Soma splits this into an external format (canonical) and a popover-internal format (view) so the user can inspect / edit in any representation without mutating the committed output unless `autoFormat=true`.
> **Format-independence is unique to soma.** Every other mainstream headless picker ties the `format` prop to a _single_ reactive state — changing format anywhere inside the picker immediately flips what the `value` text / hidden input / callbacks report. Soma splits this into an external format (canonical) and a popover-internal format (view) so the user can inspect / edit in any representation without mutating the committed output unless `autoFormat=true`.
### Format-independence — why soma does it differently
Every other library treats the picker's format as a **single reactive knob**. The consumer passes `format="hex"` → the `ChannelInput` shows hex, the textual output reads hex, form submission reads hex. If the user clicks `FormatSelect` and picks RGB, *all three* switch. The consumer has no way to keep "the value the user picked is committed to my backend as hex, regardless of what format they chose to edit it in."
Every other library treats the picker's format as a **single reactive knob**. The consumer passes `format="hex"` → the `ChannelInput` shows hex, the textual output reads hex, form submission reads hex. If the user clicks `FormatSelect` and picks RGB, _all three_ switch. The consumer has no way to keep "the value the user picked is committed to my backend as hex, regardless of what format they chose to edit it in."
Soma splits this into two concerns:
@ -314,5 +316,5 @@ The precedent for this split in the broader ecosystem is the common "display for
## Related
- [`ColorField`](../color-field/README.md) — the segmented text editor that ColorPicker composes as `ChannelInput`.