# Color Picker (/ui/components/react/color-picker)




## Usage [#usage]

```tsx
import { ColorPicker, ColorPickerInput, ColorPickerEyeDropper } from '@appica/ui-react/color-picker'
import { parseColor } from '@appica/ui-react/color'
```

```tsx
<ColorPicker defaultValue="#3b82f6" label="Brand color" />
```

That one line is a whole picker: a swatch button showing the color, and a popover holding a saturation/brightness [Color Area](/ui/components/react/color-area), a hue [Color Slider](/ui/components/react/color-slider) and a text input. Nothing is wired up by hand.

But give `ColorPicker` children and they replace the panel, and **any color component inside reads and writes the picker's color** through context. No `value`, no `onValueChange`, no lifted state:

```tsx
<ColorPicker defaultValue="#3b82f6" label="Accent">
  <ColorArea colorSpace="hsb" xChannel="saturation" yChannel="brightness" />
  <ColorSlider channel="hue" />
  <ColorSwatchPicker aria-label="Presets">
    <ColorSwatchPickerItem color="#ef4444" />
    <ColorSwatchPickerItem color="#22c55e" />
  </ColorSwatchPicker>
</ColorPicker>
```

That works for [Color Area](/ui/components/react/color-area), [Color Slider](/ui/components/react/color-slider), [Color Swatch](/ui/components/react/color-swatch) and [Color Swatch Picker](/ui/components/react/color-swatch-picker), plus the two parts that only exist here: `ColorPickerInput`, a text field that reads any color string the library can parse, and `ColorPickerEyeDropper`, a button that samples a pixel from the screen. A control that is given its own `value` opts out of reading the picker's, but still reports what it picks to it.

To control the picker, hold the color yourself:

```tsx
const [color, setColor] = useState(() => parseColor('#3b82f6'))

<ColorPicker value={color} onValueChange={setColor} label="Brand color" />
```

`onValueChange` fires on every frame of a drag; `onValueCommitted` fires once the gesture settles, which is the one to send to a server. Both hand you a `Color`, whatever you passed in.

## Examples [#examples]

### Variants [#variants]

The default trigger is a [Button](/ui/components/react/button) under the hood, so `variant` takes three of its styles plus one of its own. `flush` strips the shell - no padding, no corner, no background - leaving the swatch and the label to line up with whatever sits around them, which is what you want beside a heading or above a readout.

```tsx
import { ColorPicker } from '@appica/ui-react/color-picker'

const VARIANTS = ['ghost', 'outline', 'soft', 'flush'] as const

export default function ColorPickerVariants() {
  return (
    <div className="flex flex-wrap items-center gap-4">
      {VARIANTS.map((variant) => (
        <ColorPicker key={variant} variant={variant} defaultValue="#3b82f6" label={variant} />
      ))}
    </div>
  )
}
```

### Custom panels [#custom-panels]

Children replace the panel outright. Here the area is dropped in favour of one slider per channel, with a [Select](/ui/components/react/select) switching color space: the sliders re-key on the space and keep reporting into the same value.

`getColorChannels(space)` gives the three channels of a space, so the slider list is derived rather than hand-written.

```tsx
'use client'

import { useState } from 'react'
import { ColorPicker, ColorPickerInput } from '@appica/ui-react/color-picker'
import { ColorSlider } from '@appica/ui-react/color-slider'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@appica/ui-react/select'
import { getColorChannels, type ColorSpace } from '@appica/ui-react/color'

type PanelSpace = Extract<ColorSpace, 'rgb' | 'hsl' | 'hsb'>

const SPACES: PanelSpace[] = ['rgb', 'hsl', 'hsb']

export default function ColorPickerChannels() {
  const [space, setSpace] = useState<PanelSpace>('rgb')

  return (
    <ColorPicker defaultValue="#118844" label="Fill color" alpha popoverProps={{ className: 'w-56' }}>
      <Select size="sm" value={space} onValueChange={(next) => setSpace(next as PanelSpace)}>
        <SelectTrigger aria-label="Color space">
          <SelectValue>{space.toUpperCase()}</SelectValue>
        </SelectTrigger>
        <SelectContent>
          {SPACES.map((option) => (
            <SelectItem key={option} value={option}>
              {option.toUpperCase()}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
      {getColorChannels(space).map((channel) => (
        <ColorSlider key={channel} colorSpace={space} channel={channel} />
      ))}
      <ColorSlider channel="alpha" />
      <ColorPickerInput />
    </ColorPicker>
  )
}
```

### Presets [#presets]

A [Color Swatch Picker](/ui/components/react/color-swatch-picker) inside the panel is a shortcut past the area: click a preset and the area, the slider and the input all move to it. Drag the area afterwards and the preset deselects, because no swatch matches any more.

```tsx
import { ColorPicker, ColorPickerInput } from '@appica/ui-react/color-picker'
import { ColorArea } from '@appica/ui-react/color-area'
import { ColorSlider } from '@appica/ui-react/color-slider'
import { ColorSwatchPicker, ColorSwatchPickerItem } from '@appica/ui-react/color-swatch-picker'

const PRESETS = ['#ef4444', '#f97316', '#f59e0b', '#22c55e', '#14b8a6', '#3b82f6', '#a855f7', '#ec4899']

export default function ColorPickerPresets() {
  return (
    <ColorPicker defaultValue="#3b82f6" label="Accent">
      <ColorArea colorSpace="hsb" xChannel="saturation" yChannel="brightness" aria-label="Saturation and brightness" />
      <ColorSlider channel="hue" />
      <ColorSwatchPicker aria-label="Presets" size="xs" shape="circle">
        {PRESETS.map((preset) => (
          <ColorSwatchPickerItem key={preset} color={preset} />
        ))}
      </ColorSwatchPicker>
      <ColorPickerInput />
    </ColorPicker>
  )
}
```

### Opacity [#opacity]

`alpha` adds an opacity slider to the default panel and moves the format to `hexa`, so the opacity survives the round trip through the trigger and the input. An alpha format collapses back to its opaque twin whenever the color is fully opaque, so a solid color reads `#3b82f6` rather than `#3b82f6ff` and only grows the last two digits once there is an opacity to show. The trigger's swatch picks up the checkerboard on the same condition.

```tsx
import { ColorPicker } from '@appica/ui-react/color-picker'

export default function ColorPickerAlpha() {
  return (
    <div className="flex flex-wrap items-center gap-4">
      <ColorPicker defaultValue="#3b82f6" label="Opaque" />
      <ColorPicker defaultValue="#3b82f699" label="Translucent" alpha />
    </div>
  )
}
```

### Eyedropper [#eyedropper]

`eyedropper` adds a button that samples a single pixel from anywhere on the screen, using the browser's [EyeDropper API](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper). It renders only where that API exists (Chromium today), so a browser without it gets a panel with no dead control rather than a button that does nothing. The sampled color keeps the picker's current opacity.

```tsx
import { ColorPicker } from '@appica/ui-react/color-picker'

export default function ColorPickerEyedropper() {
  return <ColorPicker defaultValue="#f59e0b" label="Sample" eyedropper />
}
```

### Inline [#inline]

`inline` drops the trigger and the popover and renders the panel in place, for a sidebar or an editor rail. Everything else is unchanged, children included - except that the panel grows a preview [Color Swatch](/ui/components/react/color-swatch), since there is no longer a trigger showing the color.

```tsx
import { ColorPicker } from '@appica/ui-react/color-picker'

export default function ColorPickerInline() {
  return <ColorPicker inline alpha aria-label="Brand color" defaultValue="#a855f7" />
}
```

### Custom triggers [#custom-triggers]

`label={null}` leaves the swatch on its own in a square button - the usual shape in a toolbar. `swatchShape` and `swatchPosition` restyle that swatch without replacing it; it is inset from its edge by the same gap the button's height leaves above and below it.

When `variant` and those two don't reach far enough, `trigger` takes an element to open the panel from and the picker stops styling anything. The second trigger below is a plain button holding an oversized circular swatch and a label that underlines on hover - a `ColorSwatch` inside a trigger needs no `color`, since it reads the picker's.

Name a trigger that has no text with `aria-label`. The color's description is appended to whatever you pass, so the name is "Stroke color, vivid cyan" rather than a hex string spelled out.

```tsx
import { ColorPicker } from '@appica/ui-react/color-picker'
import { ColorSwatch } from '@appica/ui-react/color-swatch'

export default function ColorPickerTrigger() {
  return (
    <div className="flex flex-wrap items-center gap-8">
      <ColorPicker defaultValue="#0ea5e9" label={null} aria-label="Stroke color" />
      <ColorPicker
        defaultValue="#22c55e"
        aria-label="Background color"
        trigger={
          <button
            type="button"
            className="outline-ring group flex cursor-pointer items-center gap-3 rounded-full outline-offset-3 focus-visible:outline-2"
          >
            <ColorSwatch size={32} shape="circle" aria-hidden="true" />
            <span className="text-sm font-medium underline-offset-4 group-hover:underline">Background</span>
          </button>
        }
      />
    </div>
  )
}
```

### As a form field [#as-a-form-field]

A swatch-only picker in an [Input](/ui/components/react/input)'s `startSlot` is the shape most design tools use, and the one that belongs in a form: click the swatch for the panel, or type a value straight into the field. Focusing the field opens the panel too.

`name` on the picker renders a hidden input carrying the color written in `format`, so what gets submitted is always a color the library parsed - never the half-typed text in the visible field.

**The field is not the popover's trigger, and cannot be.** Base UI's trigger puts `type="button"` on whatever element it renders, or `role="button"` plus Space and Enter handlers when you tell it the element is not a native button. Either one stops a text box being a text box. So the picker takes `trigger={null}`, renders no trigger at all, and the field drives `open` itself.

Four details make that work:

* `trigger={null}` leaves the picker as a panel and a hidden input. `open` becomes required, since nothing else can raise it.
* `popoverProps.anchor` points the panel at the field, and `initialFocus: false` / `finalFocus: false` leave the caret where it is instead of pulling it into the panel and back. `finalFocus` matters most: its default returns focus to whatever had it before the panel opened, which here is the field - so closing would re-fire `onFocus` and reopen on the spot.
* `onOpenChange` hands you Base UI's event details, so a press on the text input can `cancel()` the dismissal it would otherwise cause. Without it, clicking into the text would close the panel that focus just opened.
* The panel dismisses itself on an outside press, but Escape only reaches it while focus is inside it. The field closes on Escape itself.

The swatch in `startSlot` is a plain [Color Swatch](/ui/components/react/color-swatch) showing the controlled value: decoration, not a control.

```tsx
'use client'

import { useRef, useState } from 'react'
import { ColorPicker } from '@appica/ui-react/color-picker'
import { ColorSwatch } from '@appica/ui-react/color-swatch'
import { Button } from '@appica/ui-react/button'
import { Field, FieldLabel } from '@appica/ui-react/field'
import { Input } from '@appica/ui-react/input'
import { type Color, formatColor, parseColor, safeParseColor } from '@appica/ui-react/color'

export default function ColorPickerField() {
  const field = useRef<HTMLDivElement>(null)
  const input = useRef<HTMLInputElement>(null)
  const [open, setOpen] = useState(false)
  const [color, setColor] = useState<Color>(() => parseColor('#3b82f6'))
  const [text, setText] = useState('#3b82f6')
  const [submitted, setSubmitted] = useState<string | null>(null)

  const write = (next: Color) => {
    setColor(next)
    setText(formatColor(next, 'hex'))
  }

  return (
    <form
      className="flex flex-col items-start gap-4"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get('brand')))
      }}
    >
      <Field className="w-52">
        <FieldLabel>Brand color</FieldLabel>
        <div ref={field}>
          <Input
            ref={input}
            className="font-mono"
            value={text}
            onFocus={() => setOpen(true)}
            onKeyDown={(event) => {
              if (event.key === 'Escape') setOpen(false)
            }}
            onChange={(event) => {
              setText(event.target.value)
              const parsed = safeParseColor(event.target.value)
              if (parsed) setColor(parsed)
            }}
            onBlur={() => setText(formatColor(color, 'hex'))}
            startSlot={<ColorSwatch color={color} size={20} shape="circle" />}
          />
        </div>
        <ColorPicker
          aria-label="Brand color"
          name="brand"
          trigger={null}
          value={color}
          onValueChange={write}
          open={open}
          onOpenChange={(next, details) => {
            if (!next && details.event?.target === input.current) return details.cancel()
            setOpen(next)
          }}
          popoverProps={{ anchor: field, initialFocus: false, finalFocus: false }}
        />
      </Field>
      <Button type="submit" size="sm">
        Submit
      </Button>
      {submitted ? <p className="text-foreground-muted font-mono text-xs">brand={submitted}</p> : null}
    </form>
  )
}
```

### Controlled [#controlled]

The value is a `Color`, not a string, so it carries a color space and an opacity that no hex string could. Convert on the way out with `formatColor`, which is what the trigger and the input do internally. The trigger is `variant="flush"` here so its swatch lines up with the readout under it.

```tsx
'use client'

import { useState } from 'react'
import { ColorPicker } from '@appica/ui-react/color-picker'
import { type Color, formatColor, parseColor } from '@appica/ui-react/color'

const FORMATS = ['hex', 'rgb', 'hsl', 'oklch'] as const

export default function ColorPickerControlled() {
  const [color, setColor] = useState<Color>(() => parseColor('#3b82f6'))

  return (
    <div className="flex w-68 flex-col items-start gap-4">
      <ColorPicker value={color} onValueChange={setColor} variant="flush" label="Theme color" />
      <dl className="grid gap-1 text-xs">
        {FORMATS.map((format) => (
          <div key={format} className="flex gap-2">
            <dt className="text-foreground-muted w-10">{format}</dt>
            <dd className="text-foreground-intense font-mono">{formatColor(color, format)}</dd>
          </div>
        ))}
      </dl>
    </div>
  )
}
```

### Disabled [#disabled]

`disabled` covers the trigger and every control inside the panel at once, in both modes. Each one takes the treatment the whole family shares: a flat muted fill inside a dashed outline, dimmed.

```tsx
import { ColorPicker } from '@appica/ui-react/color-picker'

export default function ColorPickerDisabled() {
  return (
    <div className="flex flex-wrap items-start gap-8">
      <ColorPicker defaultValue="#3b82f6" label="Locked" disabled />
      <ColorPicker inline disabled aria-label="Locked panel" defaultValue="#3b82f6" />
    </div>
  )
}
```

## RTL [#rtl]

Every Appica UI component supports right-to-left layouts out of the box. Set the `dir` attribute on a container (commonly your `<html>` element) so CSS logical properties resolve correctly, and wrap your tree in `DirectionProvider` so direction-aware behavior (roving focus, popup placement, and the like) follows the same direction.

```tsx
import { DirectionProvider } from '@appica/ui-react/providers/direction-provider'

export default function RootLayout({ children }) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <DirectionProvider dir="rtl">{children}</DirectionProvider>
      </body>
    </html>
  )
}
```

The swatch leads and the label follows, the popover aligns to the start edge (the right), and inside the panel the area and the sliders mirror with &#x2A;*←/→** swapped. Channel values, hex strings and the input stay left-to-right, since they are not language. For setup details and caveats, see the [RTL guide](/ui/docs/react/rtl).


## API reference [#api-reference]

### ColorPicker [#colorpicker]

| Prop               | Type                                                                  | Default       | Description                                                                                                                                                                                                                                                                 |
| ------------------ | ------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `value`            | `Color \| string`                                                                                 | -             | Selected color. Pass a `Color` or any CSS color string to control the component.                                                                                                                                                                                                                                   |
| `defaultValue`     | `Color \| string`                                                                                 | `'#ffffff'`   | Color selected before any interaction, when the component is uncontrolled.                                                                                                                                                                                                                                         |
| `onValueChange`    | `(value: Color) => void`                                                                          | -             | Fires on every change, including each frame of a drag inside the panel.                                                                                                                                                                                                                                            |
| `onValueCommitted` | `(value: Color) => void`                                                                          | -             | Fires once a gesture ends, with the color that was landed on.                                                                                                                                                                                                                                                      |
| `children`         | `ReactNode`                                                                                                  | default panel | Panel contents. Any `ColorArea`, `ColorSlider`, `ColorSwatch` or `ColorSwatchPicker` in here reads and writes the picker's color, so no value wiring is needed. Leave it off for the default panel: an HSB area, a hue slider and a text input, plus a preview swatch when `inline` leaves no trigger to show one. |
| `format`           | `'hex' \| 'hexa' \| 'rgb' \| 'rgba' \| 'hsl' \| 'hsla' \| 'hsb' \| 'hsba' \| 'oklch' \| 'oklcha'` | `'hex'`       | Format the trigger and the text input write the color in. Defaults to `'hexa'` when `alpha` is set. An alpha format collapses to its opaque twin whenever the color is fully opaque, so a solid color never picks up a trailing `ff`.                                                                              |
| `alpha`            | `boolean`                                                                                                    | `false`       | Add an alpha slider to the default panel, and back the preview with a checkerboard.                                                                                                                                                                                                                                |
| `eyedropper`       | `boolean`                                                                                                    | `false`       | Add a screen color-sampling button to the default panel. It renders only where the browser supports the EyeDropper API, so there is no dead control on Firefox or Safari.                                                                                                                                          |
| `inline`           | `boolean`                                                                                                    | `false`       | Render the panel in place instead of behind a trigger and a popover. The trigger props (`label`, `trigger`, `size`, the popover ones) do nothing in this mode.                                                                                                                                                     |
| `disabled`         | `boolean`                                                                                                    | `false`       | Prevent interaction and dim the trigger and every control in the panel.                                                                                                                                                                                                                                            |
| `variant`          | `'ghost' \| 'outline' \| 'soft' \| 'flush'`                                                       | `'ghost'`     | Visual style of the default trigger, from `Button`'s set. `flush` takes the button shell off entirely, leaving the swatch and the label on their own.                                                                                                                                                              |
| `size`             | `'sm' \| 'md' \| 'lg'`                                                                            | `'md'`        | Height and text scale of the default trigger.                                                                                                                                                                                                                                                                      |
| `label`            | `ReactNode`                                                                                                  | the color     | Text beside the swatch in the default trigger. Defaults to the color, formatted with `format`. Pass `null` for a swatch-only square button, and pair a non-string node with `aria-label`.                                                                                                                          |
| `swatchShape`      | `'rounded' \| 'circle'`                                                                           | `'rounded'`   | Rounded square or full circle, for the swatch on the default trigger.                                                                                                                                                                                                                                              |
| `swatchPosition`   | `'start' \| 'end'`                                                                                | `'start'`     | Which side of the label the default trigger's swatch sits on. It is inset from that edge by the same gap the button's height leaves above and below it.                                                                                                                                                            |
| `trigger`          | `ReactElement \| null`                                                                            | -             | Element to open the panel from, in place of the default swatch button. `null` renders no trigger at all, for a panel driven by `open` and pointed at something you own with `popoverProps.anchor`. `className` and the forwarded attributes land on the trigger, so they have nowhere to go in that mode.          |
| `open`             | `boolean`                                                                                                    | -             | Controlled open state of the popover. Pair with `onOpenChange`.                                                                                                                                                                                                                                                    |
| `defaultOpen`      | `boolean`                                                                                                    | `false`       | Uncontrolled initial open state of the popover.                                                                                                                                                                                                                                                                    |
| `onOpenChange`     | `(open: boolean, eventDetails) => void`                                                           | -             | Fires when the popover opens or closes. The second argument carries the `reason` and a `cancel()` that stops Base UI acting on the event.                                                                                                                                                                          |
| `side`             | `'top' \| 'right' \| 'bottom' \| 'left' \| 'inline-start' \| 'inline-end'`                        | `'bottom'`    | Preferred popover side.                                                                                                                                                                                                                                                                                            |
| `align`            | `'start' \| 'center' \| 'end'`                                                                    | `'start'`     | Popover alignment.                                                                                                                                                                                                                                                                                                 |
| `sideOffset`       | `number`                                                                                                     | `6`           | Gap between the trigger and the popover.                                                                                                                                                                                                                                                                           |
| `popoverProps`     | `Partial\<PopoverContentProps>`                                                                   | -             | Escape hatch forwarded to the inner `PopoverContent` (collision props, `className`, …).                                                                                                                                                                                                                            |
| `name`             | `string`                                                                                                     | -             | Name of the hidden input, used when submitting an HTML form.                                                                                                                                                                                                                                                       |
| `form`             | `string`                                                                                                     | -             | `id` of the `<form>` the hidden input belongs to, when it sits outside it.                                                                                                                                                                                                                                         |
| `aria-label`       | `string`                                                                                                     | -             | Accessible name. It prefixes the color's description on the trigger, and names the popover.                                                                                                                                                                                                                        |
| `className`        | `string`                                                                                                     | -             | Extra classes on the trigger, or on the panel when `inline`. Merged via `tailwind-merge`.                                                                                                                                                                                                                          |

Every other HTML attribute is forwarded to the trigger, or to the panel when `inline`. The parts are addressable through `data-slot` (`color-picker`, `color-picker-trigger`, `color-picker-panel`, `color-picker-input`, `color-picker-eyedropper`).

### ColorPickerInput [#colorpickerinput]

| Prop            | Type                                                                  | Default           | Description                                |
| --------------- | ------------------------------------------------------------------------------------------------------------ | ----------------- | --------------------------------------------------------------------------------- |
| `format`        | `'hex' \| 'hexa' \| 'rgb' \| 'rgba' \| 'hsl' \| 'hsla' \| 'hsb' \| 'hsba' \| 'oklch' \| 'oklcha'` | picker's `format` | Format the color is written in. Defaults to the enclosing picker's `format`.      |
| `inputSize`     | `'sm' \| 'md' \| 'lg'`                                                                            | `'sm'`            | Scales height, padding, and text.                                                 |
| `variant`       | `'outline' \| 'soft'`                                                                             | `'outline'`       | Field appearance - bordered or filled.                                            |
| `render`        | `ReactElement \| ((props, state) => ReactElement)`                                                | -                 | Replace the underlying element, or compose it with another component.             |
| `onValueChange` | `function`                                                                                                   | -                 | Fires when the value changes. Use when controlled.                                |
| `startSlot`     | `ReactNode`                                                                                                  | -                 | Content pinned to the start edge.                                                 |
| `endSlot`       | `ReactNode`                                                                                                  | -                 | Content pinned to the end edge.                                                   |
| `inputProps`    | `Omit\<InputProps, 'size'>`                                                                       | -                 | Props for the inner `<input>` in wrapper mode (its own `className`, handlers, …). |
| `className`     | `string`                                                                                                     | -                 | Extra classes on the input, merged via `tailwind-merge`.                          |

Every other [Input](/ui/components/react/input) prop is forwarded. The field reads any color string `parseColor` accepts, not only the format it prints, and takes bare hex digits without the `#`. It must be rendered inside a `ColorPicker`.

### ColorPickerEyeDropper [#colorpickereyedropper]

| Prop        | Type                                                                              | Default      | Description        |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ | ------------ | --------------------------------------------------------- |
| `children`  | `ReactNode`                                                                                                              | pipette icon | Icon rendered inside the button.                          |
| `variant`   | `'primary' \| 'primary-outline' \| 'secondary' \| 'soft' \| 'outline' \| 'ghost' \| 'destructive' \| 'light'` | `'ghost'`    | Visual style, from the same set `Button` offers.          |
| `size`      | `'sm' \| 'md' \| 'lg' \| 'icon-sm' \| 'icon-md' \| 'icon-lg'`                                                 | `'icon-sm'`  | Height and padding, from the same set `Button` offers.    |
| `className` | `string`                                                                                                                 | -            | Extra classes on the button, merged via `tailwind-merge`. |

Every other `<button>` attribute is forwarded. This is a plain `<button>` wearing `buttonVariants`, not the [Button](/ui/components/react/button) component, so the color picker doesn't pull Button into its chunk. It renders `null` where the browser has no `EyeDropper`, and must be rendered inside a `ColorPicker`.

The color model - `Color`, `parseColor`, `formatColor`, `convertColor` and the channel helpers - ships from `@appica/ui-react/color` and is documented on the [Color Area](/ui/components/react/color-area#api-reference) page.

## Accessibility [#accessibility]

* The trigger is a button with `aria-haspopup="dialog"`, and the panel is a labeled dialog: the popover is named from `aria-label`, or "Color picker" when you don't pass one.
* The trigger's accessible name ends with a plain-English description of the color - "Brand color, vivid blue" - because a hex string is read out digit by digit. That holds for a swatch-only trigger too, which would otherwise have no name at all.
* Each control in the panel keeps the accessibility it has on its own page: the area is a pair of sliders with a `two-dimensional slider` role description, each slider is a labeled range input, and a swatch palette is a listbox.
* Keyboard: **Space/Enter** opens the panel and moves focus into it, **Tab** cycles the controls, **Escape** closes and returns focus to the trigger. Inside, the arrow keys move by one step and **Page Up/Page Down** (or a shifted arrow) by a larger one.
* The text input commits on **Enter** or on blur, and **Escape** restores the color. Unparseable text is never committed - the field reverts to the current color.
* `disabled` reaches every control through the same context that carries the color, so a disabled picker cannot be operated from the panel either.
* Nothing depends on color alone: the value is written out as text on the trigger and in the input, and every control is reachable and reportable by keyboard.
