Color picker
A color field that opens a square, a hue slider, and a hex field in a Popover.
Color picker is a color field: a button that shows the chosen color and opens a Popover to change it. Inside are the swatches you pass, a square for saturation and brightness, a hue slider, and a hex field, so a color can be picked by eye, chosen from a brand, or pasted exactly. It stands in for the browser's own color input, whose window the page cannot style.
For the stat, the quote mark, and the ornament.
Usage
'use client';
import * as React from 'react';
import { ColorPicker } from '@/components/ui/color-picker';
import { Field, FieldLabel } from '@/components/ui/field';
export function AccentField() {
const [accent, setAccent] = React.useState('#c98a00');
return (
<Field>
<FieldLabel id="accent-label">Accent</FieldLabel>
<ColorPicker
value={accent}
onValueChange={setAccent}
label="Accent"
aria-labelledby="accent-label"
/>
</Field>
);
}The value
value is a hex color, and onValueChange receives the new one as lowercase #rrggbb. The
picker keeps no value of its own, so set value from onValueChange. The hex field takes
#rgb or #rrggbb, with or without the hash, and reports each valid color as you type. A
half-typed value waits, and leaving the field puts back the current color. Without a valid
value, the button shows placeholder in muted text.
Moving through a gray keeps the hue you were on, so dragging the square to white and back
lands on the same color family. normalizeHex is exported for checking a value of your own.
Swatches
swatches puts a row of named colors above the square, like a brand's accent, ink, and
surfaces. The swatch that matches the value shows as pressed. Pass the colors a person is most
likely to want, and leave the square and the hue for everything else.
Picking from the screen
Where the browser can sample the screen, which today is Chrome and Edge, an eyedropper button sits beside the hex field. Escape closes the sampler without changing the color. Elsewhere the button stays out, and nothing else changes.
Your own trigger
renderTrigger replaces the button with an element of your own and keeps the popover and the
panel. It receives value, which is the color as lowercase #rrggbb or null, along with
label and disabled, and returns one element. That element becomes the popover's trigger
through asChild, so it has to forward its ref and pass its props to the DOM, as a plain
button or a kit Button does. id, className, and aria-labelledby only reach the built-in
button, so name your element yourself.
Email blocks use one for the last swatch in a row of brand colors, so a custom color is one
click away and the swatch takes on the color once it is set. inkOn returns dark or white ink
for a mark drawn on a color, like that swatch's eyedropper.
import { Eyedropper } from '@phosphor-icons/react';
import { ColorPicker, inkOn } from '@/components/ui/color-picker';
<ColorPicker
value={color}
onValueChange={setColor}
label="Background"
renderTrigger={({ value }) => (
<button
type="button"
aria-label={value ? `Custom color, ${value}` : 'Custom color'}
className="flex size-7 items-center justify-center rounded-[6px] bg-fill"
style={value ? { backgroundColor: value, color: inkOn(value) } : undefined}
>
<Eyedropper aria-hidden className="size-3.5" />
</button>
)}
/>The panel without a popover
ColorPickerPanel is the same controls without the button and the popover, for a picker that
has its own panel, like a settings drawer.
Props
Color picker renders a button and a Popover. These are its own props.
| Prop | Type | Default | What it does |
|---|---|---|---|
value | string | — | The color as hex. Anything else shows the placeholder. |
onValueChange | (next: string) => void | — | Receives each new color as lowercase #rrggbb. |
swatches | { label: string; value: string }[] | none | Named colors above the square. |
label | string | "Color" | Names the square, the hue slider, and the hex field, and the button when there is no aria-labelledby. |
placeholder | string | "Pick a color" | The button's text while there is no color. |
aria-labelledby | string | none | A field label's id. The button reads as that label and then its color. |
id, disabled, className | Go on the button. disabled also reaches renderTrigger. | ||
renderTrigger | ({ value, label, disabled }) => ReactElement | none | Replaces the button. The element must forward its ref, and id, className, and aria-labelledby no longer apply. |
ColorPickerPanel takes value, onValueChange, swatches, label, and className.
Accessibility
- The button is a button. Its name is the field label and the color, like "Accent #c98a00",
or
labeland the color without a field label. Enter or Space opens the popover, Escape closes it, and focus goes back to the button. - A trigger from
renderTriggergetsaria-haspopup,aria-expanded, and the popover's open and close from the popover itself. Its size, focus ring, and name are its own. - The square is a slider. Left and Right change saturation, Up and Down change brightness, by 1% and by 10% with Shift, and it reads as "Saturation 64%, brightness 80%".
- The hue slider is a slider from 0 to 360 degrees. The arrows move it by 1 degree, Shift by 10, and Home and End go to either end.
- Dragging in the square or the slider moves its handle to the pointer and focuses it, so the keys take over where the pointer left off. Both show a 2px focus ring.
- A swatch is a button named for its color, pressed when it matches. The eyedropper button is named "Pick a color from the screen".
- Color is the whole point of this control, so the hex is always on screen in the button and the field, for anyone who cannot tell two swatches apart.
- The square, the slider, and the handles draw their own colors and keep them in forced colors, since they are the content.
When to use something else
- A short list of fixed choices, like a brand's colors and nothing else, is a Toggle group of swatches with no square at all.
- A plain Input takes a hex where a person will always paste one and never pick by eye.
Install
shadcn add @gesso/color-picker