Color Picker
Pick a colour from a preset palette, from a canvas, or by typing it — and read it back as HEX, RGB, HSL or OKLCH. Every part is opt-in, so the same component covers a nine-swatch brand palette and a full design-tool panel.
Base UI has no colour-picker primitive, so this one is built here. It stands on
two that do exist: Popover for the anchored surface,
and Base UI's Slider for the hue and opacity rails — which is where their
keyboard handling, pointer capture and RTL flip come from.
Import
import { ColorPicker } from "@forte-ui/react";Anatomy
ColorPicker.Root owns the colour and renders no element. Everything below it
is optional; a picker is whichever parts you render.
<ColorPicker.Root>
<ColorPicker.Trigger />
<ColorPicker.HiddenInput />
<ColorPicker.Popup>
<ColorPicker.Area />
<ColorPicker.HueSlider />
<ColorPicker.AlphaSlider />
<ColorPicker.Swatches />
<ColorPicker.Row>
<ColorPicker.EyeDropper />
<ColorPicker.Preview />
<ColorPicker.Format />
<ColorPicker.Input />
</ColorPicker.Row>
</ColorPicker.Popup>
</ColorPicker.Root>ColorPicker.Popup wraps Popover.Popup and takes all of its props — side,
align, sideOffset, initialFocus — so placement is tuned the same way it is
on a popover. It draws its own arrow, since its children are the picker's parts
rather than popup chrome; pass arrow={false} to drop it.
Swap Trigger and Popup for ColorPicker.Panel and the same picker sits in
the page instead.
The value
value and defaultValue are CSS colour strings, and what comes back out of
onValueChange is a CSS colour string in the current format. There is no colour
object to learn — the parsed forms are handed to you in the change details when
you want them.
Accepted on the way in: #hex (3, 4, 6 or 8 digits), rgb(), hsl(),
oklch(), oklab() and transparent, in both the legacy comma form and the
modern space form. A string that cannot be read is ignored, not thrown on,
so a half-typed value in your own state never blanks the picker.
Colours outside the sRGB gamut — oklch(0.7 0.4 30) is a common one — are
gamut-mapped, not clipped. Chroma is reduced at constant lightness and hue
until the colour fits, so the hue you asked for survives; clipping each channel
at 0 and 1, which is the one-line version, changes the hue as well and hands
back a different colour family.
Formats
format picks the notation, out of the four in formats. Switching it rewrites
the value, not the colour: the same colour comes back as #7c3aed,
rgb(124 58 237), hsl(262.1 83.3% 57.8%) or oklch(0.5413 0.2466 293.01).
That also fires onValueChange with reason "format-change", so a controlled
value never ends up in a notation the panel is no longer showing.
oklch(0.5413 0.2466 293.01)
Opacity
Rendering ColorPicker.AlphaSlider is what turns alpha on. There is no prop:
the picker omits alpha from every format while the colour is fully opaque, so a
picker without the rail emits #7c3aed and never #7c3aedff. A defaultValue
that already carries alpha keeps it either way.
#7c3aed
#7c3aed
Examples
Swatches only
ColorPicker.Swatches on its own is a palette picker — the right shape wherever
the answer has to stay on brand. colors takes any strings value accepts, and
columns sets both the grid and the arrow-key stride.
The colours come from colors; children are not read. That is what lets the
group know its own order, which is what the single tab stop and the arrow keys
are computed from. To build a row element by element — a "recently used" strip,
say — use ColorPicker.Swatch on its own, where each swatch is an independent
toggle button.
Controlled
Pass value with onValueChange. onValueCommitted fires once when an
interaction ends rather than once per pointer move, which is the one to persist
from.
The eyedropper
ColorPicker.EyeDropper samples a colour from anywhere on the screen. Where the
browser has no EyeDropper API — Firefox, and everything on iOS — it renders
nothing at all, which is deliberate: it is a shortcut to a colour the rest of
the picker can already reach, so its absence costs a user nothing, while a
button that opens no eyedropper costs them a click and their confidence in the
rest of the panel.
Triggers
The trigger shows a swatch, then its children, then the current colour in a visually hidden span — so a trigger with no children is still named, by the value itself.
In a form
ColorPicker.HiddenInput submits the colour under a name.
Disabled
disabled on the root reaches every part through context. Each part is
separately disabled rather than the panel taking pointer-events: none, so they
stay announced as disabled instead of silently vanishing from the keyboard.
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves to the next part. The canvas takes two stops (one per axis) and the swatch grid takes one for the whole grid. |
| ArrowLeft then ArrowRight | On the canvas, moves saturation. On a rail, moves the value by one step. In the swatch grid, moves by one swatch — following the writing direction. |
| ArrowUp then ArrowDown | On the canvas, moves brightness — and focus follows to the brightness axis, so the value that changed is the value announced. On a rail, moves by one step. In the swatch grid, moves by one row. |
| Shift then ArrowUp | Ten steps at once — on either axis of the canvas, and on both rails. |
| PageUp then PageDown | Ten percent of the axis, on the canvas and on both rails. |
| Home then End | Jumps to either end of an axis or a rail; first and last swatch in the grid. |
| Enter | In the text field, applies the typed colour and rewrites it in its canonical form. Elsewhere, activates the control. |
| Escape | Closes a popover picker. In the text field it also restores the canonical text — though the colour itself is already applied, since every keystroke that parses is. |
Accessibility
The swatch grid is a radiogroup, not a row of buttons, because that is what it
is: a set of mutually exclusive options with at most one current. It brings the
whole convention with it — one tab stop for the group, arrow keys between
swatches, and selection following focus — so a twenty-four colour palette costs a
keyboard user one Tab instead of twenty-four.
Two further notes:
- Colour is announced, not just shown.
ColorPicker.Triggerreads out the current value after its label;ColorPicker.Previewisaria-hidden, since a third announcement of the same colour on every drag frame is noise. For anyone who cannot use a canvas at all,ColorPicker.Inputis the exact route in and out, and it is why the text field is not optional in practice. - Under forced colours, the canvas, the rails, the swatches and the previews
opt out of substitution (
.forte-preserve-color) and take a system-coloured hairline as their boundary — because in this one component the colour is the content, and substituting it would leave four identical rectangles.
The canvas deliberately does not mirror under RTL: it is a picture of a colour space, not a line of text, and the hue on the left of every other colour tool should not move because the surrounding paragraph runs the other way. The hue and opacity rails do mirror, because a slider's start follows the writing direction, and the gradients follow them.
Theming
Every measure is a custom property on the panel, so a wider canvas, fatter rails and round swatches are five declarations and no fork.
| Property | Declared on | Controls | Default |
|---|---|---|---|
--forte-color-picker-width | ColorPicker.Panel | Width of the picker. The panel shrinks below it rather than overflowing a narrow container. | 17rem |
--forte-color-picker-padding | ColorPicker.Panel | Padding inside the panel | var(--forte-space-3) |
--forte-color-picker-gap | ColorPicker.Panel | Vertical gap between the panel's parts | var(--forte-space-3) |
--forte-color-picker-radius | ColorPicker.Panel | Corner radius of the standalone panel; the popover surface keeps --forte-popover-radius | var(--forte-radius-surface) |
--forte-color-picker-area-height | ColorPicker.Panel | Height of the saturation/brightness canvas | 8.5rem |
--forte-color-picker-area-radius | ColorPicker.Panel | Corner radius of the canvas and the swatches | var(--forte-radius-2) |
--forte-color-picker-thumb-size | ColorPicker.Panel | Diameter of the canvas handle | 0.875rem |
--forte-color-picker-thumb-ring | ColorPicker.Panel | Thickness of the ring around both kinds of handle | 2px |
--forte-color-picker-thumb-border | ColorPicker.Panel | Ring colour of the rail handles | #ffffff |
--forte-color-picker-thumb-outline | ColorPicker.Panel | The hairline outside that ring, which is what keeps a white handle visible on a pale yellow | rgb(0 0 0 / 0.35) |
--forte-color-picker-rail-thickness | ColorPicker.Panel | Thickness of the hue and opacity rails | 0.75rem |
--forte-color-picker-rail-thumb-size | ColorPicker.Panel | Diameter of a rail handle | 1.125rem |
--forte-color-picker-rail-radius | ColorPicker.Panel | Corner radius of the rails | var(--forte-radius-pill) |
--forte-color-picker-swatch-size | ColorPicker.Panel | Size of the swatch grid's cells; the grid takes the panel's width and the cells stretch to fill it, so this is a minimum | 1.25rem |
--forte-color-picker-swatch-gap | ColorPicker.Panel | Gap between swatches | var(--forte-space-2) |
--forte-color-picker-swatch-radius | ColorPicker.Panel | Corner radius of one swatch | var(--forte-radius-1) |
--forte-color-picker-checker-size | ColorPicker.Panel | Side of one square of the checkerboard drawn behind translucent colour | 8px |
--forte-color-picker-checker-light | ColorPicker.Panel | The checkerboard's light square | var(--forte-color-background) |
--forte-color-picker-checker-dark | ColorPicker.Panel | The checkerboard's dark square | var(--forte-color-border) |
--forte-color-picker-trigger-swatch-size | ColorPicker.Trigger | Size of the swatch inside the trigger | 1rem |
Four more custom properties are written inline by the component, because
they carry live state rather than theme — and because ColorPicker.Popup is
portalled to <body>, where a value declared on an ancestor in the React tree
would never reach it:
| Property | Holds |
|---|---|
--forte-color-picker-color | the current colour, alpha included |
--forte-color-picker-solid | the same colour at full alpha |
--forte-color-picker-hue | its hue in degrees, unitless |
--forte-color-picker-on-color | black or white, whichever reads on it |
Read them if you are skinning a part; do not set them.
API reference
ColorPicker.Root
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The picker's parts. | |
defaultFormat | ColorPickerFormat | hex | The notation the picker starts in. |
defaultOpen | boolean | false | Whether the popover is open when it first mounts. Only relevant when the picker is used with `ColorPicker.Trigger` and `ColorPicker.Popup`. |
defaultValue | string | #000000 | The colour the picker starts on when it is uncontrolled. |
disabled | boolean | false | Whether every part ignores interaction. |
format | ColorPickerFormat | Which notation `value` is written in. Pass it with `onFormatChange` to control the format. | |
formats | readonly ColorPickerFormat[] | ["hex", "rgb", "hsl", "oklch"] | The notations `ColorPicker.Format` offers, in order. Narrow it to the one your app stores — a design tool that writes OKLCH has no use for a HEX option that silently rounds. |
modal | boolean | "trap-focus" | false | Whether the popover takes the page over while it is open. See `Popover.Root`'s own `modal` for the three settings. |
onFormatChange | ((format: ColorPickerFormat) => void) | Called when the format changes. Switching format also re-emits the current colour through `onValueChange` with reason `"format-change"`, so a controlled `value` never disagrees with the notation on screen. | |
onOpenChange | ((open: boolean, eventDetails: PopoverRootChangeEventDetails) => void) | Called when the popover wants to open or close. | |
onValueChange | ((value: string, details: ColorPickerChangeDetails) => void) | Called on every change, including each frame of a drag. `value` is written in the current `format`; `details` carries the same colour as HSVA and RGBA, plus what moved it. | |
onValueCommitted | ((value: string, details: ColorPickerChangeDetails) => void) | Called when an interaction ENDS — pointer up, a keyboard step, a swatch press, a committed text entry. This is the one to persist from: a drag across the area fires `onValueChange` once per pointer move and this once. | |
open | boolean | Whether the popover is currently open. Pass it with `onOpenChange` to control the popover. | |
value | string | The selected colour, as a CSS colour string. Pass it with `onValueChange` to control the picker. Accepts `#hex` (3, 4, 6 or 8 digits), `rgb()`, `hsl()`, `oklch()`, `oklab()` and `transparent`, in both the legacy comma form and the modern space form. A string that cannot be read is ignored rather than throwing, so a half-typed value in your own state never blanks the picker. Named colours (`rebeccapurple`) are not accepted — see the docs page. |
ColorPicker.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The trigger's visible label. The current colour is announced after it, so a trigger with no children is still named. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
hideSwatch | boolean | false | Hide the built-in swatch, for a trigger that shows the colour some other way — a filled button, an icon that inherits it. |
ColorPicker.Popup
Renders Popover.Popup — the portal, the positioner and the surface — with the
panel inside it, so its props are drawn from all of them.
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | center | How the popup lines up with the trigger along the chosen side. |
alignOffset | number | OffsetFunction | 0 | Shifts the popup along the alignment axis, in pixels, or a function returning one. |
anchor | Element | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null | The element the popup positions against, when it should not be the trigger. Accepts an element, a ref, a getter, or a virtual element — a text selection or a right-click point. | |
arrow | boolean | true | Render the wedge pointing back at the trigger. A boolean here where `Popover` wants a child element, because this component's children are the picker's own parts and an `Arrow` mixed in among them would be layout, not chrome. `ColorPicker.Arrow` is still exported for the rare popup that composes its own header. |
arrowPadding | number | 5 | Minimum distance, in pixels, the arrow keeps from the popup's corners before it is allowed to sit off-centre (`data-uncentered`). |
backdrop | boolean | false | Render a scrim behind the popup. Off by default — a popover normally leaves the page visible and usable. Turn it on with `modal` on `Popover.Root`, where the page is already inert and the scrim is what says so. |
backdropClassName | string | Additional class name(s) for the backdrop element. The popup's own `className` cannot reach it, since the backdrop is a sibling rendered inside this component. Also where `--forte-popover-backdrop-z-index` goes. | |
children | ReactNode | The picker's parts. | |
className | string | Additional class name(s) for the popup surface. Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
collisionAvoidance | CollisionAvoidance | How the popup reacts when it would overflow the boundary — whether it flips, shifts, or stays put. | |
collisionBoundary | Boundary | clipping-ancestors | The boundary the popup tries to stay inside of. |
collisionPadding | Padding | 5 | Space, in pixels, kept between the popup and the collision boundary. |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Where the portal renders. Defaults to `document.body`; point it at a container when the popup has to live inside a specific stacking or shadow root. | |
disableAnchorTracking | boolean | false | Stops the popup re-measuring the anchor on scroll and resize. Cheaper, but the popup drifts if the anchor moves. |
keepMounted | boolean | false | Keeps the portal — and therefore the popup — in the DOM while the popover is closed. Needed when something inside must stay mounted (an iframe, a media element, uncommitted form state). |
panelClassName | string | Additional class name(s) for the inner panel — the element that owns the padding and the column layout. | |
positionerClassName | string | Additional class name(s) for the positioner element, which owns placement and `z-index`. Use it to re-stack a single popover through `--forte-popover-z-index`. | |
positionMethod | "fixed" | "absolute" | absolute | Whether the popup is positioned with `position: absolute` or `position: fixed`. |
side | Side | bottom | Which side of the trigger to place the popup on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction. |
sideOffset | number | OffsetFunction | 8 | Gap between trigger and popup, in pixels, or a function returning one. When an `Arrow` is rendered this must exceed the arrow's height or the arrow overlaps the trigger; the default leaves room for the default arrow. |
size | PopoverSize | md | Width cap for the popup. The popup shrinks to fit its content and only grows to this width when the content asks for it, so this is a ceiling rather than a fixed measure. Further clamped to the space the positioner reports as available. |
sticky | boolean | false | Keeps the popup glued to the trigger while it scrolls out of view instead of letting it detach. |
ColorPicker.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ColorPicker.Row
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ColorPicker.Area
| Prop | Type | Default | Description |
|---|---|---|---|
brightnessLabel | string | Brightness | Accessible name for the vertical axis. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
saturationLabel | string | Saturation | Accessible name for the horizontal axis. |
step | number | 0.01 | How far one arrow key moves each axis, as a fraction of the axis. `Shift` multiplies it by ten, as it does on the sliders. |
ColorPicker.HueSlider
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
label | string | Hue | Accessible name for the rail. |
ColorPicker.AlphaSlider
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
label | string | Opacity | Accessible name for the rail. |
ColorPicker.Swatches
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
colors | readonly string[] | [
"#ffffff", "#f4f4f5", "#d4d4d8", "#a1a1aa", "#71717a", "#3f3f46", "#18181b", "#000000",
"#ef4444", "#f97316", "#f59e0b", "#eab308", "#84cc16", "#22c55e", "#10b981", "#14b8a6",
"#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899",
] | The palette, as CSS colour strings. Anything `value` accepts works here, including translucent colours. |
columns | number | 8 | How many swatches per row. Also the arrow-key stride, so Up and Down move between rows rather than by one. |
label | string | Colour swatches | Accessible name for the group. |
ColorPicker.Swatch
| Prop | Type | Default | Description |
|---|---|---|---|
value* | string | The colour this swatch sets, as a CSS colour string. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
label | string | Accessible name. Defaults to the colour string, which is the only thing that is reliably true about it — a name of your own ("Brand primary") is better wherever you have one. |
ColorPicker.Preview
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ColorPicker.Value
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
ColorPicker.Format
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s) for the trigger. Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
label | string | Colour format | Accessible name for the control. |
ColorPicker.Input
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
label | string | Colour value | Accessible name for the field. |
ColorPicker.EyeDropper
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
label | string | Pick a colour from the screen | Accessible name for the button. |
ColorPicker.HiddenInput
| Prop | Type | Default | Description |
|---|---|---|---|
name* | string | The field name the colour is submitted under. |
DEFAULT_SWATCHES
The palette ColorPicker.Swatches falls back to: eight neutrals, eight warm
hues and eight cool ones. Exported alongside the component, so an app can extend
it rather than restate it.
import { DEFAULT_SWATCHES } from "@forte-ui/react";