Composites

Color Picker

A field for a colour that is data — a brand colour on a client record, the background colour written into an order’s instructions — chosen on a saturation-and-brightness area, a hue slider or by typing a code, and stored as hex. It is not a way to colour the interface: labels, projects and statuses pick from the package’s tones.

Import
import { ColorPicker, ColorPickerTrigger, … } from '@comitor/ui' — the main entry, ten parts and useColorPicker. No optional peer to install.
Runtime
A client component. Every part and useColorPicker throw outside a ColorPicker, and onValueChange is a function, so the picker lives in a client component of yours.
Origin
Ported in 3.15.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Popover, Slider, Select, Input and tokens. It was turned down in 2.1.0 for the reason in Data, not interface colour, which still holds; what changed on the way is under What the port guarantees, and the release notes are in the changelog.

Data, not interface colour

Every colour the package paints is a token that has been measured: a tone’s text against its background, in light and dark, in every palette, in the high-contrast set. A colour somebody picks freely can be anything — pale yellow, near-black, the exact grey of the page — so nothing can promise that text on it, or a border around it, will be readable. That is why the picker was left out in 2.1.0, and the reason still stands for anything that is part of the interface: a label’s colour, a project’s accent, a status. Those choose from a fixed set of tones.

What the picker is for is a colour the user is recording: the background a customer asked for, a brand colour to keep on file. It is shown back as a sample, never used to paint text or chrome. Only the parts that draw the chosen colour — the area, the swatch, the opacity track — take inline styles built from the value, and each carries a recorded exemption in the package’s contrast gate. The thumbs sit on any colour, so they use a two-tone ring that stays visible on all of them; every other border, input and button is a token.

To display a stored colour in a table or on a detail page, use Color Swatch rather than a picker.

Anatomy

ColorPicker holds the colour; the parts read and write it. You compose only the parts you need — a hex field has no use for the opacity slider — in any layout. By default the content opens in a popover from ColorPickerTrigger; with inline it is drawn in place.

anatomy.tsx
import {
  ColorPicker, ColorPickerTrigger, ColorPickerSwatch, ColorPickerContent, ColorPickerArea,
  ColorPickerHueSlider, ColorPickerAlphaSlider, ColorPickerEyeDropper,
  ColorPickerFormatSelect, ColorPickerInput,
} from '@comitor/ui'

<ColorPicker                     // a <div> holding the store; the popover root unless inline
  value={value}                  // or defaultValue — '#000000' when neither is given
  onValueChange={setValue}       // emits in the current format: '#1e66f5', 'rgb(30, 102, 245)'…
  defaultFormat="hex"            // 'hex' | 'rgb' | 'hsl' | 'hsb'
  name="background"              // inside a <form>: a hidden input posts '#rrggbb'
  inline={false}                 // true draws the content in place, no popover and no trigger
>
  <ColorPickerTrigger>           // Button variant="outline" that opens the popover
    <ColorPickerSwatch />        // role="img", named "Current color: #…" — names the trigger
  </ColorPickerTrigger>
  <ColorPickerContent>           // PopoverContent (or a plain <div> when inline), 340px wide
    <ColorPickerArea />          // saturation × brightness, a keyboard 2-D slider
    <ColorPickerHueSlider />     // 0–360°
    <ColorPickerAlphaSlider />   // 0–100% — leave it out when you store hex
    <ColorPickerEyeDropper />    // renders nothing outside Chromium
    <ColorPickerFormatSelect />  // HEX / RGB / HSL / HSB
    <ColorPickerInput />         // the typed value, one box per channel
  </ColorPickerContent>
</ColorPicker>

Basic usage

Controlled with value and onValueChange. The trigger is the package’s outline Button; the ColorPickerSwatch inside it is an image named “Current color: #1e66f5”, and aria-labelledby joins that to the field title so the button is announced as “Brand colour, Current color: #1e66f5”. The value is stored as hex, so this picker has no opacity slider and its input is withoutAlpha. Drag across the area: the counter moves once per colour that actually changed, not once per pointer event.

Brand colour

value = "#1e66f5" · onValueChange calls = 0

The pipette button is ColorPickerEyeDropper. It uses the browser’s EyeDropper API, which only Chromium browsers have — in Safari and Firefox the part renders nothing and the layout closes around it. It also renders nothing on the server and during hydration, and appears right after, so do not build a layout that depends on its width.

Inline, with opacity

inline renders ColorPickerContent as a plain <div> where you put it — for a side panel or a settings card where a popover would be one click too many. There is no trigger; style the root yourself (a border here). This one shows every part, opacity included, in rgb format. The readout under the inputs is not a part of the package: it is four calls to useColorPicker, which reads the picker’s store from inside it.

H 0° · S 68% · B 84% · A 80%

useColorPicker takes a selector and re-renders when its result changes. Select primitives, as above. The store mutates its state object in place, so useColorPicker((s) => s) never re-renders, and a selector that builds a new object on every call — (s) => ({ h: s.hsv.h }) — makes React loop. Outside a ColorPicker it throws; for anything outside, use onValueChange.

Keyboard on the area

The area’s thumb is a focusable two-axis slider. Tab to it in the inline demo above, or click the area — a press moves focus to the thumb, so the keys carry on from where you clicked.

KeyEffect
← →Saturation −1 / +1
↑ ↓Brightness +1 / −1
Shift + arrowThe same, in steps of 10
PageUp PageDownBrightness +10 / −10
Home EndSaturation 0 / 100

Everything is clamped to 0–100. The horizontal keys do not swap under dir="rtl": the area’s gradient does not mirror either, so the keys follow what is drawn. Hue and opacity are ordinary Radix sliders — arrows by 1, PageUp / PageDown by 10, Home / End to the ends.

Formats and controlled value

onValueChange writes the colour in the current format — #1e66f5, rgb(30, 102, 245), hsl(220, 91%, 54%), hsb(220, 88%, 96%), with rgba / hsla / hsba when opacity is under 100% — and value reads any of them back, so a controlled picker works in every format. ColorPickerFormatSelect switches the format; onFormatChange tells you. Try the buttons, then the select, then drag.

Set value from outside

format = hex · value = "#1e66f5"

Nothing yet — drag the area, switch the format, then drag again.

Three things the log shows. Switching the format fires onFormatChange but not onValueChange: your state keeps the old string until the colour next changes. Setting a value from outside does not echo back through onValueChange. And format is not a controlled prop — it is read once, like defaultFormat; to force a format from outside, remount the picker with a key. If what you store is hex, keep the format at hex or read the posted field below, which is hex whatever the picker shows.

In a form

Give the root a name and a hidden <input> joins the nearest ancestor <form>. Its value is always #rrggbb — lower case, no alpha — even when the picker shows RGB, as the background field below does. readOnly keeps the brand colour focusable and inspectable but unchangeable, and it still posts; disabled takes the accent out of the tab order and out of FormData.

Background colour
Brand colour (read-only)
Accent (disabled)

FormData on submit

Nothing sent yet — submit, then change the format and submit again.

There is no empty state: a picker always holds a colour (#000000 when you give none), so an optional colour field needs its own “no colour” switch next to it. And required goes only to the hidden input, which browsers leave out of constraint validation — check what you need in the submit handler.

The trigger

ColorPickerTrigger is an outline Button — pass variant to change it. Put the swatch inside it. Dice UI’s examples put it the other way round, with asChild turning the swatch itself into the trigger; the swatch is a <div role="img">, so the result opens on a click and can never be reached with Tab. Use asChild only onto something that is focusable on its own, such as a <button>.

trigger.tsx
// ✓ The swatch INSIDE the default trigger: a real <button>, reachable with Tab, named by the swatch.
<ColorPickerTrigger>
  <ColorPickerSwatch />
</ColorPickerTrigger>

// ✓ asChild onto an element that is focusable on its own.
<ColorPickerTrigger asChild>
  <button type="button" className="…">Pick a colour</button>
</ColorPickerTrigger>

// ✗ Upstream's pattern. The swatch is a <div role="img">: a mouse can click it, Tab never reaches it.
<ColorPickerTrigger asChild>
  <ColorPickerSwatch />
</ColorPickerTrigger>

What the port guarantees

Behaviour you can rely on that the Dice UI original did not give you — each point compared against the upstream source.

  • The area works from the keyboard. Upstream it was pointer-only: nothing focusable, no role, no name, so a keyboard or screen-reader user could only type numbers. Its thumb is now a named role="slider" with aria-roledescription “2D slider” and a value text that reads both axes, driven by the keys above; a press on the area focuses it.
  • Every control has a name. The hue and opacity thumbs are named and read “210 degrees” and “80%”; upstream they were two bare “slider”s. The eyedropper button and the format select are named too — upstream the select announced only “HEX”.
  • The thumbs show on any colour. Each has a 2px paper border inside a 1px ink ring — a light and a dark tone that do not change with theme. For any colour under them, one of the two clears 3:1 (the worst case, around #DB16A5, still gets 4.21:1 from one of them). Upstream’s area thumb was a white border that vanished on pale colours. Focus is an outline, since the ring is already taken.
  • Controlled works in every format. Upstream read value as hex only while emitting rgb(…) in RGB format, so the first drag of a controlled RGB, HSL or HSB picker turned it black.
  • The hue survives grey and black. When value comes back as exactly the string just emitted, the picker does not rebuild its position from RGB. Upstream did, and grey and black carry no hue: dragging to the bottom of the area snapped the hue slider to red, and rounding made the thumb jitter on every step in HSL and HSB.
  • One call per change. onValueChange fires only when the emitted string changes. Upstream fired twice per drag step, and even when moving the hue of a grey changed nothing.
  • Typing is not undone. The inputs keep your draft until you leave them, and update the colour whenever the draft is valid. Upstream re-rendered the canonical value on every key, so you could not backspace in the middle of #1a2b3c or clear a channel box.
  • Hex the way people paste it. #abc expands to #aabbcc (upstream accepted it and turned it black), and a code without its # — as brand guides often print it — is accepted.
  • Read-only means read-only. readOnly locks the area, both sliders, the inputs and the eyedropper; upstream it reached only the hidden form input.
  • Fits the package. The trigger is the outline button rather than a colour sample on a solid brand-coloured button; every string comes from the colorPicker namespace, with a labels prop; cancelling the eyedropper with Esc no longer logs a warning; and the swatch and the opacity track share one checkerboard with Color Swatch, a fixed #ccc / #fff that does not follow the theme, so the same 50% colour looks the same in light and dark.

Language

Nothing in the picker is visible text except the format names. Its strings are accessible names and value texts — the area, the sliders, each channel box, the swatch, the eyedropper, the format select — in the colorPicker namespace. The ones with a number inside are functions: areaValueText(saturation, brightness), hueValueText(degrees), alphaValueText(percent), swatchLabel(color). The channel names are deliberately long (“Red color component (0-255)”) because nothing visible stands next to those boxes. Switch the language and read the names back:

Accessible names in the DOM (en)

The labels prop beats the nearest ComitorLocaleProvider, which beats English. The field’s own title is your string. Where the provider goes: Get started → Set the language.

Props

ColorPicker — every other prop goes to the root <div>.

PropTypeDefaultDescription
valuestring—Controlled colour. Reads every format the picker emits — #rgb, #rrggbb, rgb()/rgba(), hsl()/hsla(), hsb()/hsba() with whole-number channels. A hex value keeps the alpha the picker already has. Anything else (a named colour, 8-digit hex, oklch, decimal hue) is drawn as black, silently.
defaultValuestring'#000000'Uncontrolled starting colour, read the same way as value.
onValueChange(value: string) => void—Fires once per real change, with the colour written in the current format. hex carries no alpha, so moving the opacity in hex format fires nothing. Changing the format does not fire either — the next colour change arrives in the new format.
defaultFormat'hex' | 'rgb' | 'hsl' | 'hsb''hex'Format of the inputs, the swatch’s name and onValueChange at first render.
format'hex' | 'rgb' | 'hsl' | 'hsb'—Read once, at first render, like defaultFormat — the picker does not follow later changes of this prop. To switch from outside, remount with a key.
onFormatChange(format: ColorPickerFormat) => void—Fires when ColorPickerFormatSelect changes the format.
namestring—Name of the hidden input rendered after the root, only when a form element is an ancestor. Its value is always #rrggbb, lower case, no alpha, whatever the format.
inlinebooleanfalseDraws ColorPickerContent in place as a plain div — no popover, and no ColorPickerTrigger (it needs the popover).
readOnlybooleanfalseEverything stays visible and focusable, but the area, both sliders, the inputs and the eyedropper change nothing. The hidden input still posts.
disabledbooleanfalseDisables the trigger, area, sliders, eyedropper, format select and inputs, and the hidden input — a disabled picker posts nothing.
requiredbooleanfalsePassed to the hidden input, which browsers leave out of constraint validation — so in practice it does nothing. The picker always holds a colour anyway.
open / defaultOpen / onOpenChange / modalPopover props—The popover’s state, as on Popover. Ignored with inline.
labelsPartial<ColorPickerMessages>colorPicker namespaceOverrides single strings — every one is an accessible name or value text. Beats the nearest ComitorLocaleProvider, which beats English.
dir'ltr' | 'rtl'from DirectionProvider, else 'ltr'Passed to the context. The hue and alpha thumbs mirror (Radix), their gradients do not; the area and its arrow keys never mirror.
asChildbooleanfalseRenders your element as the root div.

The parts. Each one throws outside a ColorPicker, and each sets its own data-slot (color-picker-area, color-picker-input…) for styling.

PropTypeDefaultDescription
ColorPickerTriggerPopoverTrigger + Button propsvariant='outline'Opens the popover. Put ColorPickerSwatch inside to name it. asChild only onto an element that can take focus on its own — a button.
ColorPickerContentPopoverContent props—A column 340px wide (w-85) with 16px padding and gaps. PopoverContent normally, a div when the root is inline.
ColorPickerAreadiv props + asChild—Saturation left to right, brightness bottom to top, painted from the current hue. aria-label or aria-labelledby goes to the thumb, which is the focusable role='slider'. A pointer press focuses the thumb.
ColorPickerHueSliderRadix Slider Root props—0–360 in steps of 1. aria-label / aria-labelledby go to the thumb; its value text is “210 degrees”.
ColorPickerAlphaSliderRadix Slider Root props—Opacity 0–100 over a checkerboard. Its value text is “80%”. Leave it out when the stored value is hex — hex drops alpha.
ColorPickerSwatchdiv props + asChild—role='img' named “Current color: …” in the current format; 32px by default. A checkerboard shows under a colour with alpha.
ColorPickerEyeDropperButton props—Picks a colour from the screen through window.EyeDropper. Returns null on the server, during hydration, and in browsers without the API — Safari and Firefox. Icon-only unless you pass children; outline variant. Esc cancels quietly.
ColorPickerFormatSelectSelect props + size, className, aria-labelsize='sm'HEX / RGB / HSL / HSB, named “Color format”.
ColorPickerInputInput props + withoutAlpha—One box per channel of the current format, each with its own accessible name, plus an opacity box unless withoutAlpha. What you type shows as typed; the colour changes only once the text is valid, and leaving the box writes back the canonical value. Hex accepts #abc and a code without # (1e66f5).

The hook. Prefer onValueChange for anything outside the picker; the hook is for what renders inside it.

PropTypeDefaultDescription
useColorPicker(selector)(state: { color: { r, g, b, a }; hsv: { h, s, v, a }; open: boolean; format }) => T—Reads the nearest ColorPicker’s store and re-renders when the selected slice changes. Throws outside a ColorPicker. Return primitives or a stored object as-is: the state object is mutated in place, so selecting the whole state never updates, and a selector that builds a new object every call loops.

Known limitations

  • Hex drops alpha. The hex format and the hidden form input are always #rrggbb; moving the opacity in hex format changes the swatch but fires no onValueChange. If you store hex, leave out ColorPickerAlphaSlider and pass withoutAlpha.
  • format is not controlled. It seeds the picker once; later changes of the prop are ignored. Changing the format also fires no onValueChange.
  • Unreadable values turn black. value understands only what the picker itself emits, with whole-number channels. A named colour, 8-digit hex, oklch() or hsl(210.5, …) is drawn as black without a warning — normalise stored values to hex.
  • Controlled means echoed, not enforced. The picker updates its own state before it calls onValueChange, so a parent that ignores the call does not undo the change. To stop changes, use readOnly or disabled.
  • No empty value, and no validation. The picker always holds a colour, and required cannot block a submit. Only an ancestor <form> counts — a picker tied to a form elsewhere through the form attribute posts nothing.
  • The eyedropper is Chromium-only, and appears only after hydration.
  • Right-to-left is partial. The hue and opacity gradients do not mirror while Radix mirrors their thumbs, as upstream; the area never mirrors.
  • ColorPickerTrigger asChild onto the swatch is mouse-only. Keep the swatch inside the default trigger.

Accessibility

  • The area thumb is role="slider" with aria-roledescription, aria-valuenow for saturation and an aria-valuetext that reads both axes (“Saturation 64%, brightness 96%”). Pass aria-label or aria-labelledby on ColorPickerArea to rename it; both go to the thumb. The same holds for the two sliders.
  • Name the trigger after the field. On its own it is announced as “Current color: #…” from the swatch inside; point aria-labelledby at the field title and the swatch, as the demos do, so it says what the colour is for.
  • The swatch is role="img" named with the colour in the current format, and keeps its colour under Windows High Contrast (forced-color-adjust: none).
  • readOnly keeps every part focusable and adds aria-readonly to the three thumbs; disabled takes them out of the tab order and sets aria-disabled on the area thumb.
  • Focus on the thumbs is a 2px outline-ring outline with a 2px offset; the two-tone ring around each thumb keeps it visible on any colour.
  • The colour itself is not text-contrast-checked and cannot be: it is the user’s data. Show its code next to the swatch wherever the exact value matters, so nobody has to judge it by eye.

Related

  • Color Swatch — display a stored colour in a table or on a detail page.
  • Colors — the tones to use for labels, projects and statuses instead of a free colour.
  • Popover — what the content opens in, and where open, onOpenChange and modal come from.
  • Slider — the plain range control the hue and opacity sliders are built on.
  • Field — titles, descriptions and errors around a picker in a form.