A color picker lets users select colors from a gradient area, channel sliders, or predefined swatches. It builds on the native <input type="color"> experience with a fully customizable, accessible UI.
Features
- Supports RGBA, HSLA, HSBA, and HEX color formats
- Color area with draggable thumb for hue/saturation/brightness
- Per-channel sliders and numeric inputs
- Predefined color swatches
- Eyedropper tool (Chrome and Edge only)
- Keyboard and touch interaction support
- Form submission and reset support
- Inline rendering without a trigger popup
Installation
npm install @zag-js/color-picker @zag-js/react
import * as colorPicker from "@zag-js/color-picker"
import { normalizeProps, useMachine } from "@zag-js/react"
import { useId } from "react"
export function ColorPicker() {
const service = useMachine(colorPicker.machine, {
id: useId(),
defaultValue: colorPicker.parse("#ff0000"),
})
const api = colorPicker.connect(service, normalizeProps)
return (
<div {...api.getRootProps()}>
<label {...api.getLabelProps()}>Pick a color</label>
<div {...api.getControlProps()}>
<button {...api.getTriggerProps()}>
<div {...api.getSwatchProps({ value: api.value })} />
</button>
<input {...api.getChannelInputProps({ channel: "hex" })} />
</div>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
{/* Color area */}
<div {...api.getAreaProps()}>
<div {...api.getAreaBackgroundProps()} />
<div {...api.getAreaThumbProps()} />
</div>
{/* Hue slider */}
<div {...api.getChannelSliderProps({ channel: "hue" })}>
<div {...api.getChannelSliderTrackProps({ channel: "hue" })} />
<div {...api.getChannelSliderThumbProps({ channel: "hue" })} />
</div>
{/* Alpha slider */}
<div {...api.getChannelSliderProps({ channel: "alpha" })}>
<div {...api.getTransparencyGridProps()} />
<div {...api.getChannelSliderTrackProps({ channel: "alpha" })} />
<div {...api.getChannelSliderThumbProps({ channel: "alpha" })} />
</div>
{/* Channel inputs */}
<div>
<input {...api.getChannelInputProps({ channel: "hex" })} />
<input {...api.getChannelInputProps({ channel: "alpha" })} />
</div>
{/* Eyedropper */}
<button {...api.getEyeDropperTriggerProps()}>Pick</button>
</div>
</div>
{/* Hidden form input */}
<input {...api.getHiddenInputProps()} />
</div>
)
}
<script setup>
import * as colorPicker from "@zag-js/color-picker"
import { normalizeProps, useMachine } from "@zag-js/vue"
import { computed } from "vue"
const service = useMachine(colorPicker.machine, {
id: "color-picker-1",
defaultValue: colorPicker.parse("#ff0000"),
})
const api = computed(() => colorPicker.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<label v-bind="api.getLabelProps()">Pick a color</label>
<div v-bind="api.getControlProps()">
<button v-bind="api.getTriggerProps()">
<div v-bind="api.getSwatchProps({ value: api.value })" />
</button>
</div>
<div v-bind="api.getPositionerProps()">
<div v-bind="api.getContentProps()">
<div v-bind="api.getAreaProps()">
<div v-bind="api.getAreaBackgroundProps()" />
<div v-bind="api.getAreaThumbProps()" />
</div>
</div>
</div>
</div>
</template>
import * as colorPicker from "@zag-js/color-picker"
import { normalizeProps, useMachine } from "@zag-js/solid"
import { createMemo, createUniqueId } from "solid-js"
export function ColorPicker() {
const service = useMachine(colorPicker.machine, {
id: createUniqueId(),
defaultValue: colorPicker.parse("#ff0000"),
})
const api = createMemo(() => colorPicker.connect(service, normalizeProps))
return (
<div {...api().getRootProps()}>
<label {...api().getLabelProps()}>Pick a color</label>
<div {...api().getControlProps()}>
<button {...api().getTriggerProps()}>
<div {...api().getSwatchProps({ value: api().value })} />
</button>
</div>
</div>
)
}
Setting the initial color
Use defaultValue with colorPicker.parse(...) to set the starting color. The parse function converts a CSS color string into a Color object.
const service = useMachine(colorPicker.machine, {
defaultValue: colorPicker.parse("#ff0000"),
})
Controlled color picker
Use value and onValueChange to drive the color externally.
Keep the value as a Color object rather than a string. Converting back and forth between strings can introduce rounding errors.
const [color, setColor] = useState(colorPicker.parse("#ff0000"))
const service = useMachine(colorPicker.machine, {
value: color,
onValueChange(details) {
// details => { value: Color, valueAsString: string }
setColor(details.value)
},
})
Color formats
The default output format is rgba. Set format to change it. The value and valueAsString in change callbacks will reflect the active format.
| Format | Description |
|---|
rgba | Red, Green, Blue, Alpha (default) |
hsla | Hue, Saturation, Lightness, Alpha |
hsba | Hue, Saturation, Brightness, Alpha |
const service = useMachine(colorPicker.machine, {
format: "hsla",
onValueChange(details) {
// details.value is an HSLAColor
console.log(details.valueAsString) // "hsla(0, 100%, 50%, 1)"
},
})
Use defaultFormat for uncontrolled initial format, and onFormatChange to react to user-driven format switches:
const service = useMachine(colorPicker.machine, {
defaultFormat: "hsba",
onFormatChange(details) {
console.log(details.format) // "rgba" | "hsla" | "hsba"
},
})
Channel inputs and sliders
Render individual channel inputs with getChannelInputProps. Match the channels to the active format.
// RGBA channels
<input {...api.getChannelInputProps({ channel: "red" })} />
<input {...api.getChannelInputProps({ channel: "green" })} />
<input {...api.getChannelInputProps({ channel: "blue" })} />
<input {...api.getChannelInputProps({ channel: "alpha" })} />
// Hex input (format-independent)
<input {...api.getChannelInputProps({ channel: "hex" })} />
Render sliders with getChannelSliderProps:
<div {...api.getChannelSliderProps({ channel: "hue" })}>
<div {...api.getChannelSliderTrackProps({ channel: "hue" })} />
<div {...api.getChannelSliderThumbProps({ channel: "hue" })} />
</div>
Color swatches
Add preset swatches to help users pick common colors quickly. Use getSwatchTriggerProps for clickable swatches and getSwatchProps for static color previews.
const presets = ["#ff0000", "#00ff00", "#0000ff", "#ffff00"]
<div {...api.getSwatchGroupProps()}>
{presets.map((color) => (
<button key={color} {...api.getSwatchTriggerProps({ value: color })}>
<div {...api.getSwatchProps({ value: color })} />
</button>
))}
</div>
To close the picker after a swatch is selected:
const service = useMachine(colorPicker.machine, {
closeOnSelect: true,
})
Eyedropper
The eyedropper lets users sample any color from the current page. It is only supported in Chrome and Edge.
<button {...api.getEyeDropperTriggerProps()}>
Pick color from screen
</button>
Color preview
Show the currently selected color using getSwatchProps with api.value:
<div {...api.getSwatchProps({ value: api.value })} />
{/* Without the alpha channel */}
<div {...api.getSwatchProps({ value: api.value, respectAlpha: false })} />
Control popup placement and offset with the positioning prop:
const service = useMachine(colorPicker.machine, {
positioning: {
placement: "bottom-start",
gutter: 8,
},
})
Inline rendering
Set inline to render the picker body directly in the page without a trigger button or popup container:
const service = useMachine(colorPicker.machine, {
inline: true,
})
Form usage
Set name to include the color value in form submissions. The machine renders a hidden <input> element automatically.
const service = useMachine(colorPicker.machine, {
name: "color-preference",
})
// Render the hidden input
<input {...api.getHiddenInputProps()} />
The initial color value when rendered. Use colorPicker.parse(string) to create a Color from a CSS string. Defaults to #000000.
The controlled color value. Use with onValueChange for controlled mode.
onValueChange
(details: { value: Color, valueAsString: string }) => void
Fires continuously as the user drags within the color area or sliders.
onValueChangeEnd
(details: { value: Color, valueAsString: string }) => void
Fires when the user releases the pointer after dragging. Useful for debounced updates.
The controlled color format. Defaults to rgba.
The initial color format when uncontrolled. Defaults to rgba.
onFormatChange
(details: { format: ColorFormat }) => void
Called when the user switches color formats.
The controlled open state of the popup.
The initial open state when uncontrolled.
onOpenChange
(details: { open: boolean, value: Color }) => void
Called when the popup opens or closes.
Whether the popup closes when a swatch is selected. Defaults to false.
Render the color picker inline without a trigger and popup wrapper.
Disable all user interaction.
Make the color picker read-only.
The name attribute for the hidden form input.
Popup placement options (e.g. placement, gutter, offset).
Element to focus when the popup opens.
Styling
Every part of the color picker exposes a data-part attribute. Use these to target parts in CSS.
Parts reference
| Part | Element | Description |
|---|
root | div | The root container |
label | label | The label element |
control | div | Wraps the trigger and text input |
trigger | button | Opens the popup |
positioner | div | Positions the popup |
content | div | The popup content |
area | div | The 2D color gradient area |
areaThumb | div | Draggable thumb within the area |
areaBackground | div | Gradient background of the area |
channelSlider | div | A single channel slider |
channelSliderTrack | div | Track of a channel slider |
channelSliderThumb | div | Thumb of a channel slider |
channelInput | input | Numeric input for a channel |
transparencyGrid | div | Checkerboard grid for alpha display |
swatchGroup | div | Container for swatch triggers |
swatchTrigger | button | A clickable color swatch |
swatch | div | A color preview element |
eyeDropperTrigger | button | Opens the native eyedropper |
formatTrigger | button | Cycles through color formats |
formatSelect | select | Dropdown for color format selection |
State attributes
/* Open/closed state on control, trigger, and content */
[data-part="control"][data-state="open"] { }
[data-part="trigger"][data-state="closed"] { }
[data-part="content"][data-state="open"] { }
/* Focus state on control and label */
[data-part="control"][data-focus] { }
[data-part="label"][data-focus] { }
/* Disabled state */
[data-part="label"][data-disabled] { }
[data-part="control"][data-disabled] { }
[data-part="trigger"][data-disabled] { }
[data-part="swatch-trigger"][data-disabled] { }
/* Swatch selected state */
[data-part="swatch-trigger"][data-state="checked"] { }
[data-part="swatch-trigger"][data-state="unchecked"] { }
Accessibility
The color picker follows the WAI-ARIA Color Picker pattern. The color area and sliders use role="slider" with appropriate aria-label, aria-valuemin, aria-valuemax, and aria-valuenow attributes.
Keyboard interactions
| Key | Description |
|---|
ArrowUp | Increase the value on the focused axis or channel |
ArrowDown | Decrease the value on the focused axis or channel |
ArrowLeft | Move left in the color area or decrease the channel value |
ArrowRight | Move right in the color area or increase the channel value |
Shift + Arrow | Move in larger steps |
Page Up / Page Down | Increase or decrease value by a larger step |
Home / End | Set to minimum or maximum value |
Enter | Confirm a channel input value |
Escape | Close the popup |
Tab | Move focus between interactive parts |