Skip to content

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.

color-picker/basic.tsx

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.

color-picker/inline.tsx

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.

color-picker/formats.tsx

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.

color-picker/alpha.tsx

#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.

color-picker/swatches.tsx

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.

color-picker/controlled.tsx
Everything here follows the picker.

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.

color-picker/eyedropper.tsx
#10b981

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.

color-picker/trigger.tsx

In a form

ColorPicker.HiddenInput submits the colour under a name.

color-picker/form.tsx

Disabled

color-picker/disabled.tsx

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

Keyboard interactions
KeyBehaviour
TabMoves to the next part. The canvas takes two stops (one per axis) and the swatch grid takes one for the whole grid.
ArrowLeft then ArrowRightOn 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 ArrowDownOn 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 ArrowUpTen steps at once — on either axis of the canvas, and on both rails.
PageUp then PageDownTen percent of the axis, on the canvas and on both rails.
Home then EndJumps to either end of an axis or a rail; first and last swatch in the grid.
EnterIn the text field, applies the typed colour and rewrites it in its canonical form. Elsewhere, activates the control.
EscapeCloses 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.Trigger reads out the current value after its label; ColorPicker.Preview is aria-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.Input is 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.

color-picker/theming.tsx
Theming tokens for ColorPicker
PropertyDeclared onControlsDefault
--forte-color-picker-widthColorPicker.PanelWidth of the picker. The panel shrinks below it rather than overflowing a narrow container.17rem
--forte-color-picker-paddingColorPicker.PanelPadding inside the panelvar(--forte-space-3)
--forte-color-picker-gapColorPicker.PanelVertical gap between the panel's partsvar(--forte-space-3)
--forte-color-picker-radiusColorPicker.PanelCorner radius of the standalone panel; the popover surface keeps --forte-popover-radiusvar(--forte-radius-surface)
--forte-color-picker-area-heightColorPicker.PanelHeight of the saturation/brightness canvas8.5rem
--forte-color-picker-area-radiusColorPicker.PanelCorner radius of the canvas and the swatchesvar(--forte-radius-2)
--forte-color-picker-thumb-sizeColorPicker.PanelDiameter of the canvas handle0.875rem
--forte-color-picker-thumb-ringColorPicker.PanelThickness of the ring around both kinds of handle2px
--forte-color-picker-thumb-borderColorPicker.PanelRing colour of the rail handles#ffffff
--forte-color-picker-thumb-outlineColorPicker.PanelThe hairline outside that ring, which is what keeps a white handle visible on a pale yellowrgb(0 0 0 / 0.35)
--forte-color-picker-rail-thicknessColorPicker.PanelThickness of the hue and opacity rails0.75rem
--forte-color-picker-rail-thumb-sizeColorPicker.PanelDiameter of a rail handle1.125rem
--forte-color-picker-rail-radiusColorPicker.PanelCorner radius of the railsvar(--forte-radius-pill)
--forte-color-picker-swatch-sizeColorPicker.PanelSize of the swatch grid's cells; the grid takes the panel's width and the cells stretch to fill it, so this is a minimum1.25rem
--forte-color-picker-swatch-gapColorPicker.PanelGap between swatchesvar(--forte-space-2)
--forte-color-picker-swatch-radiusColorPicker.PanelCorner radius of one swatchvar(--forte-radius-1)
--forte-color-picker-checker-sizeColorPicker.PanelSide of one square of the checkerboard drawn behind translucent colour8px
--forte-color-picker-checker-lightColorPicker.PanelThe checkerboard's light squarevar(--forte-color-background)
--forte-color-picker-checker-darkColorPicker.PanelThe checkerboard's dark squarevar(--forte-color-border)
--forte-color-picker-trigger-swatch-sizeColorPicker.TriggerSize of the swatch inside the trigger1rem

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:

PropertyHolds
--forte-color-picker-colorthe current colour, alpha included
--forte-color-picker-solidthe same colour at full alpha
--forte-color-picker-hueits hue in degrees, unitless
--forte-color-picker-on-colorblack or white, whichever reads on it

Read them if you are skinning a part; do not set them.

API reference

ColorPicker.Root

Props for ColorPickerRoot
PropTypeDefaultDescription
childrenReactNodeThe picker's parts.
defaultFormatColorPickerFormathexThe notation the picker starts in.
defaultOpenbooleanfalseWhether the popover is open when it first mounts. Only relevant when the picker is used with `ColorPicker.Trigger` and `ColorPicker.Popup`.
defaultValuestring#000000The colour the picker starts on when it is uncontrolled.
disabledbooleanfalseWhether every part ignores interaction.
formatColorPickerFormatWhich notation `value` is written in. Pass it with `onFormatChange` to control the format.
formatsreadonly 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.
modalboolean | "trap-focus"falseWhether 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.
openbooleanWhether the popover is currently open. Pass it with `onOpenChange` to control the popover.
valuestringThe 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

Props for ColorPickerTrigger
PropTypeDefaultDescription
childrenReactNodeThe trigger's visible label. The current colour is announced after it, so a trigger with no children is still named.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
hideSwatchbooleanfalseHide 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.

Props for ColorPickerPopup
PropTypeDefaultDescription
alignAligncenterHow the popup lines up with the trigger along the chosen side.
alignOffsetnumber | OffsetFunction0Shifts the popup along the alignment axis, in pixels, or a function returning one.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullThe 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.
arrowbooleantrueRender 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.
arrowPaddingnumber5Minimum distance, in pixels, the arrow keeps from the popup's corners before it is allowed to sit off-centre (`data-uncentered`).
backdropbooleanfalseRender 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.
backdropClassNamestringAdditional 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.
childrenReactNodeThe picker's parts.
classNamestringAdditional class name(s) for the popup surface. Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
collisionAvoidanceCollisionAvoidanceHow the popup reacts when it would overflow the boundary — whether it flips, shifts, or stays put.
collisionBoundaryBoundaryclipping-ancestorsThe boundary the popup tries to stay inside of.
collisionPaddingPadding5Space, in pixels, kept between the popup and the collision boundary.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullWhere 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.
disableAnchorTrackingbooleanfalseStops the popup re-measuring the anchor on scroll and resize. Cheaper, but the popup drifts if the anchor moves.
keepMountedbooleanfalseKeeps 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).
panelClassNamestringAdditional class name(s) for the inner panel — the element that owns the padding and the column layout.
positionerClassNamestringAdditional 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"absoluteWhether the popup is positioned with `position: absolute` or `position: fixed`.
sideSidebottomWhich side of the trigger to place the popup on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction.
sideOffsetnumber | OffsetFunction8Gap 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.
sizePopoverSizemdWidth 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.
stickybooleanfalseKeeps the popup glued to the trigger while it scrolls out of view instead of letting it detach.

ColorPicker.Panel

Props for ColorPickerPanel
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ColorPicker.Row

Props for ColorPickerRow
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ColorPicker.Area

Props for ColorPickerArea
PropTypeDefaultDescription
brightnessLabelstringBrightnessAccessible name for the vertical axis.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
saturationLabelstringSaturationAccessible name for the horizontal axis.
stepnumber0.01How 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

Props for ColorPickerHueSlider
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
labelstringHueAccessible name for the rail.

ColorPicker.AlphaSlider

Props for ColorPickerAlphaSlider
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
labelstringOpacityAccessible name for the rail.

ColorPicker.Swatches

Props for ColorPickerSwatches
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
colorsreadonly 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.
columnsnumber8How many swatches per row. Also the arrow-key stride, so Up and Down move between rows rather than by one.
labelstringColour swatchesAccessible name for the group.

ColorPicker.Swatch

Props for ColorPickerSwatch
PropTypeDefaultDescription
value*stringThe colour this swatch sets, as a CSS colour string.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
labelstringAccessible 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

Props for ColorPickerPreview
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ColorPicker.Value

Props for ColorPickerValue
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

ColorPicker.Format

Props for ColorPickerFormatSelect
PropTypeDefaultDescription
classNamestringAdditional class name(s) for the trigger. Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
labelstringColour formatAccessible name for the control.

ColorPicker.Input

Props for ColorPickerInput
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
labelstringColour valueAccessible name for the field.

ColorPicker.EyeDropper

Props for ColorPickerEyeDropper
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
labelstringPick a colour from the screenAccessible name for the button.

ColorPicker.HiddenInput

Props for ColorPickerHiddenInput
PropTypeDefaultDescription
name*stringThe 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";