ColorPicker

A colour chosen by dragging — a saturation square or a wheel, a hue scale, and opacity.

A colour chosen by dragging, not by typing. The controls are the colour model made visible: a square where saturation runs across and brightness runs up — or a wheel where hue is the angle and saturation the radius — under scales for whichever channels the shape does not carry. Composition is the API — a picker with no opacity is one with no ColorPicker.Alpha in it, not one with a prop turned off.

Nothing about a drag crosses to JavaScript. The four channels are shared values, and the hue behind the square, both thumb fills and the preview swatch are colours computed from them on the UI thread; the picked colour bridges back on change and again on release, the same as any other input here.

Installation

ColorPicker ships with the library — no separate install.

import { ColorPicker, Button, Card, Text, Surface } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add color-picker

Usage

<ColorPicker defaultValue="#22c55e" onValueCommit={setBrand}>
  <ColorPicker.Area />
  <ColorPicker.Hue />
  <ColorPicker.Preview showValue />
</ColorPicker>

Composition

<ColorPicker>
  <ColorPicker.Field label="Accent" />   {/* the strip above everything */}
  <ColorPicker.Swatches colors={[…]} />
  <ColorPicker.Area />                    {/* …or <ColorPicker.Wheel /> */}
  <ColorPicker.Channel channel="hue" />   {/* the readout above a track */}
  <ColorPicker.Hue />
  <ColorPicker.Brightness />
  <ColorPicker.Alpha />
  <ColorPicker.Preview showValue />
</ColorPicker>

Every part is optional and they render in the order you write them, so the layout is yours: presets above the square, the preview at the top, opacity left out entirely.

The picker stores hue, saturation, value and alpha rather than the string it hands back. That is not an implementation detail you can ignore — it is why the thumb stays where you left it. A fully black colour is #000 whatever hue and saturation produced it, so a picker that stored its own output would lose the thumb the moment you dragged into a corner and have nowhere to put it back.

Examples

A labelled panel

The layout the parts are shaped for: a strip that names the colour and prints it, the square, then a readout naming the track under it. Every piece is optional and none of them knows about the others — this is a composition, not a variant.

<ColorPicker value={color} onValueChange={setColor}>
  <ColorPicker.Field label="Accent" />
  <Surface variant="secondary" padding="sm" className="gap-3 rounded-2xl">
    <ColorPicker.Area height={280} />
    <ColorPicker.Channel channel="hue" />
    <ColorPicker.Hue />
  </Surface>
</ColorPicker>

A wheel instead of a square

Swap Area for Wheel and hue becomes the angle, saturation the radius. Brightness then has no dimension left on the disc, so it takes a track of its own — a wheel without one can only ever pick bright colours.

<ColorPicker value={color} onValueChange={setColor}>
  <ColorPicker.Field label="Brand" />
  <ColorPicker.Wheel />
  <ColorPicker.Channel channel="brightness" />
  <ColorPicker.Brightness />
</ColorPicker>

Controlled

Hold the colour in state and apply it to something. onValueChange fires on every frame of a drag; onValueCommit fires once on release — reach for it when the change writes anywhere.

const [color, setColor] = useState('#22c55e');

<ColorPicker value={color} onValueChange={setColor}>
  <ColorPicker.Area />
  <ColorPicker.Hue />
  <ColorPicker.Preview showValue />
</ColorPicker>

<Button className="w-full" style={{ backgroundColor: color }}>
  Save theme
</Button>

With opacity

Add ColorPicker.Alpha and the colour gains a fourth channel. format decides how it is written: hex grows an #rrggbbaa pair and the other two switch to their a forms, but only once the colour is actually translucent — an opaque colour written with a trailing ff is the same colour spelled in a way half the parsers in the world reject.

<ColorPicker defaultValue="rgba(59, 130, 246, 0.6)" format="rgb" onValueCommit={save}>
  <ColorPicker.Area height={150} />
  <ColorPicker.Hue />
  <ColorPicker.Alpha />
  <ColorPicker.Preview showValue />
</ColorPicker>

Presets first

Most colour choices are one of eight, and the square is for the ninth. Putting ColorPicker.Swatches above it makes the common case a single tap without hiding the rest. Unreadable strings are dropped rather than rendered as black — a row of presets with a mystery black one in it is worse than a shorter row.

<ColorPicker defaultValue="#f97316" onValueCommit={setColor} size="sm">
  <ColorPicker.Swatches
    colors={['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899', '#0f172a']}
  />
  <ColorPicker.Area height={120} />
  <ColorPicker.Hue />
  <ColorPicker.Preview showValue />
</ColorPicker>

Just a hue

There is no minimum picker. A control that only needs a hue is the hue scale and nothing else, and it still reports a full colour.

<ColorPicker defaultValue="#3b82f6" onValueChange={setAccent}>
  <ColorPicker.Hue />
</ColorPicker>

Disabled

<ColorPicker defaultValue="#64748b" disabled>
  <ColorPicker.Area height={110} />
  <ColorPicker.Hue />
  <ColorPicker.Preview showValue />
</ColorPicker>

Versions

Accent card

A labelled strip over the square, and a readout naming the track under it.

<ColorPicker value={color} onValueChange={setColor}>
  <ColorPicker.Field label="Accent" />
  <Surface variant="secondary" padding="sm" className="gap-3 rounded-2xl">
    <ColorPicker.Area height={280} />
    <ColorPicker.Channel channel="hue" />
    <ColorPicker.Hue />
  </Surface>
</ColorPicker>

Wheel

Hue around and saturation out, with brightness on a track of its own.

<ColorPicker value={color} onValueChange={setColor}>
  <ColorPicker.Field label="Brand" />
  <ColorPicker.Wheel />
  <ColorPicker.Channel channel="brightness" />
  <ColorPicker.Brightness />
  <ColorPicker.Channel channel="alpha" />
  <ColorPicker.Alpha />
</ColorPicker>

Variants

size

  • sm
  • md (default)
  • lg
<ColorPicker size="sm" defaultValue="#8b5cf6">…</ColorPicker>
<ColorPicker size="md" defaultValue="#8b5cf6">…</ColorPicker>
<ColorPicker size="lg" defaultValue="#8b5cf6">…</ColorPicker>

API Reference

ColorPicker

PropTypeDefaultDescription
classNamestring
valuestringControlled colour. Leave unset and pass defaultValue to run uncontrolled.
defaultValuestring'#ff0000'Starting colour when uncontrolled.
onValueChange(color: string) => voidFires on every frame of a drag — cheap updates only. The string is written in format.
onValueCommit(color: string) => voidFires once when a drag ends. The place for expensive side effects.
formatColorFormat'hex'How the colour is written on the way out. hex gains an #rrggbbaa alpha pair, and the other two switch to their a forms, only when the colour is actually translucent.
disabledbooleanfalse
hapticsbooleanfalseA tick when a drag ends and when a preset is picked. Off by default — needs the optional expo-haptics, and is silent without it. There is no tick during a drag: a colour has no steps to cross, so a tick could only be a buzz proportional to speed.

ColorPicker.Area

PropTypeDefaultDescription
classNamestring
heightnumberHeight of the square in points. Defaults to the picker's size.
thumbClassNamestringExtra classes for the draggable thumb.

ColorPicker.Hue

PropTypeDefaultDescription
classNamestring
thumbClassNamestringExtra classes for the draggable thumb.

ColorPicker.Alpha

PropTypeDefaultDescription
classNamestring
thumbClassNamestringExtra classes for the draggable thumb.

ColorPicker.Preview

PropTypeDefaultDescription
classNamestring
showValuebooleanPrint the colour beside the swatch, in the picker's format. The string is built on the UI thread and only crosses to JavaScript when it differs from the last one, so a drag that is not changing the rounded value costs nothing.
swatchClassNamestringExtra classes for the swatch.
valueClassNamestringExtra classes for the printed value.

ColorPicker.Swatches

PropTypeDefaultDescription
classNamestring
colorsstring[]The presets, in any format ColorPicker can read.
swatchSizenumberDiameter of one swatch in points. Defaults to the picker's size.
swatchClassNamestringExtra classes for one swatch.

ColorPicker.Field

PropTypeDefaultDescription
classNamestring
labelstringWhat the colour is for — "Accent", "Background", a layer name.
showValuebooleanPrint the current colour beside the swatch, in the picker's format. On by default: the strip exists to say what the colour is, and a swatch alone cannot be read out, copied down or typed into a design tool.
swatchClassNamestringExtra classes for the swatch.

ColorPicker.Channel

PropTypeDefaultDescription
classNamestring
channelColorPickerChannelWhich channel to read.
labelstringOverrides the channel's own name.
format(value: number) => string'hex'Writes the number yourself. Receives degrees for hue and a percentage for the other three, both already rounded.

ColorPicker.Brightness

PropTypeDefaultDescription
classNamestring
thumbClassNamestringExtra classes for the draggable thumb.

ColorPicker.Wheel

PropTypeDefaultDescription
classNamestring
sizenumbermdDiameter in points. Defaults to the picker's size.
thumbClassNamestringExtra classes for the draggable thumb.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Runs controlled (value + onValueChange) or uncontrolled (defaultValue). Both read #rgb, #rgba, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl() and hsla(); a string the picker cannot read leaves it on the colour it already has, rather than snapping to black.

ColorPicker.Preview's printed value is built on the UI thread and only crosses to JavaScript when it differs from the last one, so a drag that is not changing the rounded value costs nothing.

The square is adjustable to a screen reader in both axes: increment and decrement move saturation, and a Brighter / Darker pair moves brightness. Hue and opacity are adjustable on their own. Their spoken values update when a drag ends rather than during it — nobody is listening to the middle of a gesture, and a value that re-announced every frame would be a hundred announcements for one drag.

The conversion helpers the picker is built on are exported too — parseColor, formatColor, hsvToHex, hsvToRgb, rgbToHsv, hsvToHsl, hsvToCss and isValidColor. Every one is a worklet, so they can be called from an animated style as well as from ordinary code.

The wheel, and what it cannot carry

A square holds two channels because it has two axes. A wheel holds two as well — an angle and a radius — so swapping one for the other trades brightness for a shape, not for a third channel. ColorPicker.Brightness is the track that takes it back; a wheel without one can only pick colours at full brightness.

The wheel is deliberately not mirrored under RTL, unlike the square and the tracks. Those have a start and an end, so they have a direction to be read in. A wheel has neither: reversing which way round the spectrum runs would move every colour somewhere else without making any of them easier to find.

There is no conic gradient to draw a hue ring with, so it is approximated by 120 solid wedges given a hair of overlap — three degrees each is under a pixel of colour step at any size this is drawn at, and the overlap stops the seams reading as lighter spokes. The ring is built once at module scope: it is the same wheel in every picker.

On this page