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 anduseColorPicker. No optional peer to install.- Runtime
- A client component. Every part and
useColorPickerthrow outside aColorPicker, andonValueChangeis a function, so the picker lives in a client component of yours. - Origin
- Ported in
3.15.0from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Popover, Slider, Select, Input and tokens. It was turned down in2.1.0for 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.
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.
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.
| Key | Effect |
|---|---|
| ← → | Saturation −1 / +1 |
| ↑ ↓ | Brightness +1 / −1 |
| Shift + arrow | The same, in steps of 10 |
| PageUp PageDown | Brightness +10 / −10 |
| Home End | Saturation 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.
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>.
// ✓ 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"witharia-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
paperborder inside a 1pxinkring — 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
valueas hex only while emittingrgb(…)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
valuecomes 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.
onValueChangefires 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
#1a2b3cor clear a channel box. - Hex the way people paste it.
#abcexpands 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.
readOnlylocks 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
colorPickernamespace, with alabelsprop; 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/#fffthat 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>.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | 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. |
defaultValue | string | '#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. |
name | string | — | 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. |
inline | boolean | false | Draws ColorPickerContent in place as a plain div — no popover, and no ColorPickerTrigger (it needs the popover). |
readOnly | boolean | false | Everything stays visible and focusable, but the area, both sliders, the inputs and the eyedropper change nothing. The hidden input still posts. |
disabled | boolean | false | Disables the trigger, area, sliders, eyedropper, format select and inputs, and the hidden input — a disabled picker posts nothing. |
required | boolean | false | Passed 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 / modal | Popover props | — | The popover’s state, as on Popover. Ignored with inline. |
labels | Partial<ColorPickerMessages> | colorPicker namespace | Overrides 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. |
asChild | boolean | false | Renders 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.
| Prop | Type | Default | Description |
|---|---|---|---|
ColorPickerTrigger | PopoverTrigger + Button props | variant='outline' | Opens the popover. Put ColorPickerSwatch inside to name it. asChild only onto an element that can take focus on its own — a button. |
ColorPickerContent | PopoverContent props | — | A column 340px wide (w-85) with 16px padding and gaps. PopoverContent normally, a div when the root is inline. |
ColorPickerArea | div 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. |
ColorPickerHueSlider | Radix Slider Root props | — | 0–360 in steps of 1. aria-label / aria-labelledby go to the thumb; its value text is “210 degrees”. |
ColorPickerAlphaSlider | Radix 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. |
ColorPickerSwatch | div props + asChild | — | role='img' named “Current color: …” in the current format; 32px by default. A checkerboard shows under a colour with alpha. |
ColorPickerEyeDropper | Button 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. |
ColorPickerFormatSelect | Select props + size, className, aria-label | size='sm' | HEX / RGB / HSL / HSB, named “Color format”. |
ColorPickerInput | Input 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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 noonValueChange. If you store hex, leave outColorPickerAlphaSliderand passwithoutAlpha. formatis not controlled. It seeds the picker once; later changes of the prop are ignored. Changing the format also fires noonValueChange.- Unreadable values turn black.
valueunderstands only what the picker itself emits, with whole-number channels. A named colour, 8-digit hex,oklch()orhsl(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, usereadOnlyordisabled. - No empty value, and no validation. The picker always holds a colour, and
requiredcannot block a submit. Only an ancestor<form>counts — a picker tied to a form elsewhere through theformattribute 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 asChildonto the swatch is mouse-only. Keep the swatch inside the default trigger.
Accessibility
- The area thumb is
role="slider"witharia-roledescription,aria-valuenowfor saturation and anaria-valuetextthat reads both axes (“Saturation 64%, brightness 96%”). Passaria-labeloraria-labelledbyonColorPickerAreato 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-labelledbyat 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). readOnlykeeps every part focusable and addsaria-readonlyto the three thumbs;disabledtakes them out of the tab order and setsaria-disabledon the area thumb.- Focus on the thumbs is a 2px
outline-ringoutline 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.