<!-- GENERATED by scripts/build-llms.mjs from llms/forms.md — do not edit this file. -->

# `lr-color-picker`

- **Import** `import '@aceshooting/lyra-ui/components/lr-color-picker.js';` (stable tag alias; registers the tag)
- **Class** `LyraColorPicker`, also available unregistered from `@aceshooting/lyra-ui/components/forms/color-picker/color-picker.class.js`
- **Family** `components/forms/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 43 parts, 34 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-color-picker`

A form-associated colour picker with label, hint and error chrome: a compact swatch trigger that
opens a popover holding a saturation/brightness grid, a hue slider, an optional alpha slider, a text
field accepting any parseable CSS colour, an optional predefined palette, and — where the browser
supports it — a screen eyedropper.

Host `aria-describedby` references resolve in the host's root and precede the trigger's local error,
hint and current-value guidance. Same-ID target replacement, removal, reinsertion, reconnection and
document adoption keep those relationships current. Inline mode has no trigger.

Own or fieldset disablement discards an unfinished native text draft without committing it and
blocks palette/format actions immediately, including before disabled rendering settles. A valid
enabled draft still commits on normal blur or Enter. Format changes retain their existing
programmatic event silence. Pointer and eyedropper cancellation behavior remains unchanged.

**Rewritten in 8.0.0.** It used to wrap a bare native `<input type="color">`; it is now a real
picker built from the pieces above. Two consequences for existing code:

- **`::part(input)` now names the panel's text field, not the swatch.** The swatch is
  `::part(trigger)`. A stylesheet that targeted `input` to size or tint the visible control has to
  move to `trigger`.
- In popup mode the visible control is a `<button>` (`[part="trigger"]`): `focus()` targets it and
  `click()` activates it, toggling the popup. There is no native colour input in the shadow tree to
  reach for. Inline mode has no trigger; `focus()` targets the grid handle and `click()` activates
  the editable value field. In either mode `blur()` blurs whichever internal control is active.

`value` is always serialized in the active `format` (`hex` by default), so reading it back after any
interaction gives a canonical string in exactly one syntax; switching `format`, `opacity` or
`uppercase` **re-serializes the same colour** rather than reinterpreting it. Input is far more
permissive than output: hex (3/4/6/8 digit), `rgb()`/`rgba()`, `hsl()`/`hsla()`, `hsv()`/`hsva()`,
CSS colour names, and any other colour syntax the browser itself parses are all accepted. A value
that is not a colour at all is **kept verbatim** rather than silently replaced, so a consumer's own
sentinel survives a round trip. The public `value` and `defaultValue` both default to the empty
string, matching the mirrored form contract; the uncommitted preview and editable field still begin
at black (`#000000`). A bare `required` picker is therefore value-missing, and `form.reset()` returns
to the declarative value or to empty when none was supplied.

Colour is never the only channel carrying state: the trigger's `aria-describedby` points at a
visually-hidden span spelling the current value out in text, the panel shows it in an editable
field, and the selected palette swatch is marked with `aria-pressed` plus a check mark rather than
a tint alone.

Validation is projected onto both editing owners with an explicit stateful `aria-invalid`. A
required empty picker starts pristine, so the popup trigger and panel value input both expose
`aria-invalid="false"` even though `checkValidity()` is false. Once focus leaves the control, the
resulting `user-invalid` state changes both to `"true"`; `reportValidity()` reveals the same state
without requiring a blur. A valid value or `form.reset()` clears the projection back to `"false"`,
while visible error content makes it `"true"`. Inline mode omits the trigger, but its value input
follows the same pristine/user-invalid contract.

**Not the same control as `lr-swatch-picker`.** This one is freeform: `swatches` is a shortcut row
_beside_ a saturation grid, a hue ramp and a text field, and the committed value can be any colour
the browser parses. `<lr-swatch-picker>` offers exactly its `options` and nothing else, with
`radiogroup` semantics rather than a popover. Reach for it when the answer must be one of N
designer-chosen colours; reach for this when it must not.

**Properties:** the shared
form properties `name`, `value`, `defaultValue` (canonical content attribute `value`),
`customError` (`custom-error`), `disabled`, and
`required`, plus `label`, `hint`, `errorText`
(`error-text`), `accessibleLabel` (`aria-label`), and `size: LyraSize = 'm'`
(reflected — the same visual-density scale as `lr-input`, applied to the centered visible swatch;
accepts `2xs`/`xs`/`s`/`m`/`l`/`xl` and `small`/`medium`/`large`; the interactive target
independently retains the `--lr-icon-button-size` floor),
and:

- `format: 'hex' | 'rgb' | 'hsl' | 'hsv' = 'hex'` — the syntax `value` is **written** in. Parsing is
  always permissive regardless of it. The format button cycles through the four in that order
- `opacity: boolean = false` — enables the alpha channel: an opacity slider appears in the panel and
  the serialized value gains its alpha-carrying twin (`hexa`/`rgba`/`hsla`/`hsva`). With it unset,
  picking a palette entry forces alpha back to 1
- `uppercase: boolean = false` — serializes `value` in upper case (`#FF0000` rather than `#ff0000`);
  applies to the whole string, function names included (`RGB(255, 0, 0)`)
- `swatches: string | string[] | LyraColorPickerSwatch[] = ''` — a predefined palette, given as a
  `;`-separated string, an array of colour strings, or an array of
  `{ color: string; label?: string; disabled?: boolean }` objects. Any colour the picker can parse
  is accepted; blank entries are dropped. An entry that is _not_ parseable is kept in the list and
  still renders a swatch — it just paints no colour (the bare checkerboard) and clicking it does
  nothing, so filter the palette yourself if that matters. `label` becomes the swatch's accessible
  name; a missing, empty, or whitespace-only label falls back to announcing the raw colour string.
  `disabled` marks that one swatch non-actionable, independent of the whole control's own
  `disabled`: it renders a genuinely disabled `<button>` (no tab stop, no hover/press affordance)
  and clicking it commits nothing; omitted or `false` renders the swatch exactly as before this
  field existed. The palette container renders only while the normalized list is non-empty
- `withoutFormatToggle: boolean = false` (attribute `without-format-toggle`) — removes the button
  that cycles between formats. `noFormatToggle` (`no-format-toggle`) is the Shoelace spelling and
  reaches the same behavior; either one wins
- `inline: boolean = false` (reflected) — renders the full panel in normal flow and omits the popup
  trigger. In this mode `focus()` targets the grid handle and `click()` targets the value field.
  The panel stays visible regardless of `open`; `show()`/`hide()` still update that lifecycle state
  and its events so switching back to popup mode has a deterministic result
- `hoist: boolean = false` (reflected) — uses fixed popup positioning to escape clipping
  ancestors; the default absolute strategy stays in the component's local scrolling context
- `positioningStrategy: PlaceStrategy = 'absolute'` (attribute `positioning-strategy`, reflected) —
  see `<lr-popover>` (`llms/components/lr-popover.md`): the one property `<lr-popover>`,
  `<lr-dropdown>`, `<lr-select>`, `<lr-tooltip>` and `<lr-color-picker>` all spell the same way,
  `'absolute' | 'fixed'`. `absolute` is this control's mirrored default and keeps the panel in the
  component's local scrolling context; `fixed` escapes most clipping ancestors. An unsupported value
  resolves back to the default, and a change applies live while the panel is open.
  `hoist: boolean = false` is its retained exact alias (`hoist` ⇔ `positioning-strategy="fixed"`);
  writing either spelling updates the other, so the two attributes can never disagree. Prefer
  `positioning-strategy` in new code. This property always reports the instance's own authored
  value (or the mirrored default); when neither spelling is authored, the panel is actually placed
  with the cascading `--lr-positioning-strategy` custom property honored ahead of that default —
  see **Themeable custom properties** below
- `withLabel: boolean = false` (`with-label`, reflected) and `withHint: boolean = false`
  (`with-hint`, reflected) — SSR hints that the corresponding slots are populated, so their chrome
  is present before client-side slot observation
- `placement: Placement = 'bottom-start'` (reflected) — preferred panel placement, from the Floating
  UI vocabulary. The resolved side still flips/shifts to stay in the viewport, and the
  `left`/`right` component is swapped under RTL
- `open: boolean = false` (reflected) — whether the popup panel is open. Assigning `true` while the
  control is effectively disabled is ignored, and a `disabled` that flips on while the popup is
  already open closes it. Inline rendering remains visible at either value

**Methods:** `show()` opens the popup (a no-op while effectively disabled), and `hide()` closes it
and returns focus to the trigger. In popup mode `click()` activates the trigger and therefore
toggles the popup, while `focus(options?)` targets that trigger. Inline mode has no trigger:
`click()` activates the editable value field and `focus(options?)` targets the grid handle.
`blur()` blurs the active internal control in either mode; `click()` and `focus()` are inert while
effectively disabled. In inline mode `show()`/`hide()` update `open` and lifecycle events without
hiding the in-flow panel.
`getFormattedValue(format?)` returns the current colour in any of the eight output formats —
`'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva'`, defaulting to `'hex'` —
independently of `format`/`opacity`, honouring `uppercase`. Use it to read, say, an `rgba()` string
out of a picker configured to store hex, without touching `value`.
`getHexString(hue, saturation, brightness, alpha = 100)` converts percent-scaled HSV(A) channels
to six-digit hex, or eight-digit hex when alpha is below 100, honoring `uppercase`.
The shared form methods are
`getForm()`, `checkValidity()`, `reportValidity()`, `setCustomValidity(message)`, and
`resetValidity()`. `resetValidity()` clears only consumer-supplied custom validity and recomputes
the current intrinsic constraints; it leaves `value`/`defaultValue` and prior interaction state
unchanged.

**Slots:** `label`, `hint`, `error`. Slotted label text replaces the `label` property's visible text
and names both the trigger and dialog; a host `aria-label` remains the strongest naming override.

**Events:** each serialized value-changing edit emits a bubbling/composed native `InputEvent` named
`input`, followed by the no-detail `lr-input` compatibility alias. A completed interaction emits
one bubbling/composed native `Event` named `change`, followed by `lr-change` with
`detail: { value }`. The commit pair occurs on pointer release, key release, swatch click, an
accepted text-field change/Enter, or an eyedropper result. A drag or repeated key can therefore
emit several `input`/`lr-input` edit pairs but only one `change`/`lr-change` commit pair.
Pointer drags are reversible previews: `pointercancel`, lost pointer capture, mid-gesture
disablement, disconnection, or document adoption restores the pre-gesture colour and submitted
form value without emitting another `input` or a commit pair. A direct consumer `value` assignment
during a drag is authoritative instead: it retires the gesture and remains current.
Also emitted are `lr-show` / `lr-after-show` and `lr-hide` / `lr-after-hide` (the panel opened
or closed — never emitted for a declaratively-open picker's first render, nor for a close caused by
disconnection; because this panel has no opening animation, each `lr-after-*` immediately follows
its matching lifecycle event in the completed update), and `focus`/`blur`
(exactly one bubbling/composed native `FocusEvent` relay when focus
enters or leaves the internal controls in either popup or inline mode), and `lr-invalid`
(no detail) once when native validity fails. A change that
doesn't move the serialized value emits nothing, so dragging within a single rounded colour is
silent.

**Keyboard.** The grid handle, hue handle and opacity handle are each a real `role="slider"` with a
localized name and `aria-valuetext`. Arrow keys step by 1 (percent or degree), Shift+Arrow by 10,
and Home/End jump to that axis' extremes; ArrowLeft/ArrowRight swap meaning under RTL, ArrowUp/Down
never do. One discrete press pairs a keydown (`input`/`lr-input`) with a keyup
(`change`/`lr-change`); OS key repeat re-fires the edit pair but still commits once. The panel is
Escape-dismissible and returns focus to the trigger; a pointerdown outside the element closes it
too. Both routes are topmost-aware through the shared nonmodal overlay stack, so an older color
picker remains open under a newer Lyra popup and receives the manager's focus handoff when that top
layer closes.

**CSS parts:** `base` (permanent compatibility name on the same field wrapper as `color-picker`),
`color-picker` (the field wrapper; it is the same node as `base` and `form-control`),
`form-control` (the field wrapper; it is the same node as `base` and `color-picker`),
`form-control-label` (the label; `label` is its permanent compatibility name), `trigger-container`
(the row wrapping the trigger), `trigger` (the swatch button that
opens the panel), `panel` (the positioned `role="dialog"` surface), `grid` (the
saturation/brightness square) and `grid-handle` (its draggable, keyboard-operable handle),
`slider` and `slider-handle` (carried by **both** ramps), `hue-slider` / `hue-slider-handle` and
`opacity-slider` / `opacity-slider-handle` (each also carrying the shared `slider`/`slider-handle`
token, so `::part(slider)` styles both ramps while `::part(hue-slider)` reaches only one; the
opacity pair renders only with `opacity` set), `preview` (the current-colour dot beside the ramps),
`input` (the text field holding the serialized value), `format-button` (the format-cycling button,
absent with either format-toggle suppression property), plus `format-button__base`,
`format-button__start`/`format-button__prefix`, `format-button__label`,
`format-button__end`/`format-button__suffix`, and `format-button__caret`;
`eyedropper-button` / `eye-dropper-button` (rendered only where the browser exposes the EyeDropper
API), with the corresponding `eyedropper-button__base|start|label|end|caret` and Shoelace
`eye-dropper-button__base|prefix|label|suffix|caret` aliases; `swatches` (the palette container, rendered only when the normalized `swatches`
list is non-empty), `swatch` (one palette entry), `swatch-selected` (a token **added to** the
swatch matching the current value — state after `::part()` never matches, so write
`::part(swatch-selected)`), `hint`, `error`.

The eyedropper aliases are also addressable individually as `eyedropper-button__base`,
`eyedropper-button__start`, `eyedropper-button__label`, `eyedropper-button__end`,
`eyedropper-button__caret`, `eye-dropper-button__base`, `eye-dropper-button__prefix`,
`eye-dropper-button__label`, `eye-dropper-button__suffix`, and `eye-dropper-button__caret`.

**The required marker.** `required` with a non-empty `label` paints the library's shared marker on
`[part="form-control-label"]` — the one `::after` rule described under "The required-field marker"
above, not a copy of it, so `--lr-form-control-required-content`,
`--lr-form-control-required-color` and `--lr-form-control-required-offset` retune or suppress it
here exactly as they do on `lr-input`. With no label text the part is hidden and no glyph is
painted.

**Themeable custom properties:** `--lr-color-picker-swatch-size` sizes the centered visible swatch,
not the button's minimum target. Its private default follows `size` (default `'m'` reads `2.5rem`,
`'2xs'` reads `1.25rem`, etc.), matching the visual-density ladder `lr-input` uses. The trigger's
inline and block sizes are each
`max(var(--lr-color-picker-swatch-size), var(--lr-icon-button-size))`: compact tiers center a smaller
swatch inside the shared hit-area floor, while a larger swatch expands the target with it. The
panel's geometry has its own public hook set; each hook inherits from an ancestor, and a direct host
value wins:

- `--lr-color-picker-grid-inline-size` (default `var(--lr-size-15rem)`) and
  `--lr-color-picker-grid-block-size` (default `var(--lr-size-8rem)`) — the saturation/brightness
  square's width and height. The first also caps the palette row's width.
- `--lr-color-picker-grid-handle-size` (default `var(--lr-size-1rem)`) — diameter of the grid handle.
- `--lr-color-picker-slider-block-size` (default `var(--lr-size-0-75rem)`) — thickness of the
  **visible** hue/opacity ramp. The slider's own pointer target stays floored at 24px regardless, so
  thinning the ramp never shrinks the touch target.
- `--lr-color-picker-slider-handle-size` (default `var(--lr-size-1-25rem)`) — diameter of a slider
  handle.
- `--lr-color-picker-palette-swatch-size` (default `var(--lr-size-1-5rem)`) — size of a palette
  swatch, and of the `preview` dot.
- `--lr-color-picker-checker-color` (default `var(--lr-color-border)`) and
  `--lr-color-picker-checker-size` (default `var(--lr-size-0-5rem)`) — tint and cell size of the
  alpha checkerboard drawn behind the trigger, preview, swatches and opacity ramp.
- `--lr-color-picker-hue-stops` — the hue ramp's own gradient stops, defaulting to the six-stop sRGB
  hue wheel. Both text directions read the same list; only the gradient's direction differs.
  Override it to theme a wide-gamut or perceptually-uniform ramp.

The upstream aliases `--grid-width`, `--grid-height`, `--grid-handle-size`, `--slider-height`, and
`--slider-handle-size` feed the corresponding Lyra geometry properties above; Shoelace's
`--swatch-size` feeds `--lr-color-picker-palette-swatch-size`. An explicit `--lr-*` value takes
precedence over its alias.

Three more are **state, not configuration** — the component rewrites each inline on every render, so
setting them from a stylesheet has no lasting effect: `--lr-color-picker-swatch-color` (the live
colour painted on the trigger, preview, slider handles and palette swatches),
`--lr-color-picker-grid-hue` (the grid's fully-saturated base hue), and
`--lr-color-picker-opacity-gradient` (the opacity ramp's transparent-to-opaque gradient, built from
the current colour and text direction). Read them if you need the resolved colour; don't assign them.

The popup panel is a floating surface and paints from the **shared overlay-surface family** (16.0.0):
`--lr-overlay-surface` (default `var(--lr-color-surface-overlay)`), `--lr-overlay-border` (default
`var(--lr-color-border)`) and `--lr-overlay-shadow-anchored` (default `var(--lr-shadow-m)`). None is
declared on `:host`, so one declaration on `:root` — or on any ancestor, to scope it — retints this
surface together with every other floating surface in the library. `--lr-overlay-radius` (default `var(--lr-radius)`) reaches it only as the middle arm of
`--lr-color-picker-radius`, which still wins when set.

`--lr-positioning-strategy` (16.0.0) is the same cascading `absolute`/`fixed` override
`<lr-popover>` (`llms/components/lr-popover.md`) documents, read from computed style each time the
panel is (re)positioned. An explicit `positioning-strategy`/`hoist` on the instance always wins
over it; otherwise it wins over this control's own mirrored `absolute` default. One declaration on
`:root`, a theme, or a single clipping ancestor changes every unset color picker beneath it instead
of authoring `positioning-strategy`/`hoist` on each instance.

**Additional API surface:**

- `click()` — In popup mode activates the trigger, opening or closing the panel; in inline mode
  activates the editable value field instead. It is a no-op while effectively disabled.
- `--lr-color-picker-gap` — Gap between field chrome and panel rows. Default: `var(--lr-space-xs)`.
- `--lr-color-picker-radius` — Trigger, grid, field and panel corner radius. Default: `var(--lr-radius)`.
- `--lr-color-picker-border-color` — Resting trigger border color. Default: `var(--lr-color-border)`.
- `--lr-color-picker-hover-border-color` — Hover border color, shared by the trigger, handles, text
  field, format/eyedropper buttons and palette swatches. Default: `var(--lr-color-brand)`.
- `--lr-color-picker-selected-border` — Selected palette-swatch border. Default:
  `var(--lr-color-brand)`.
- `--lr-color-picker-selected-check-color` — Checkmark on the selected palette swatch. Default:
  `var(--lr-color-surface)`.
- `--lr-color-picker-swatch-disabled-opacity` — Opacity of a palette swatch whose own entry sets
  `disabled`. Default: `0.5`.

```html
<lr-color-picker
  name="accent"
  label="Accent colour"
  hint="Used for links and primary buttons."
  format="rgb"
  opacity
  uppercase
  placement="bottom-end"
  swatches="#e11d48;#2563eb;#16a34a"
></lr-color-picker>
<script type="module">
  import "@aceshooting/lyra-ui/components/forms/color-picker/color-picker.js";
  const picker = document.querySelector("lr-color-picker");
  // Objects give each entry a real accessible name:
  picker.swatches = [
    { color: "#e11d48", label: "Rose" },
    { color: "#2563eb", label: "Blue" },
  ];
  picker.addEventListener("change", () => {
    console.log(picker.value); // e.g. "RGBA(225, 29, 72, 1.00)"
    console.log(picker.getFormattedValue("hexa")); // e.g. "#E11D48FF"
  });
  picker.show();
</script>
```

**Known gotchas:**

- **The `input` CSS part moved.** It is the panel's text field as of 8.0.0; the swatch is `trigger`.
- The eyedropper button is only in the DOM where `window.EyeDropper` exists (feature-detected once,
  at connect). Dismissing the eyedropper rejects the platform promise, which is treated as a
  cancellation, not an error — nothing is surfaced and nothing changes.
- A non-colour `value` is preserved verbatim, so `value` is not guaranteed to be parseable as a
  colour just because the element accepted it. `getFormattedValue()` always reports the picker's
  own working colour, which in that case is whatever was last understood.
- A disconnect/reconnect cycle (a drag-and-drop reparent, a virtualized list reordering) closes the
  panel rather than leaving it rendered at a stale, frozen position, and an abandoned half-typed
  entry in the text field is discarded rather than reappearing on the next open.
