Skip to content

Color Picker

Pick a color 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 color-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 color 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 color strings, and what comes back out of onValueChange is a CSS color string in the current format. There is no color 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.

Colors 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 color 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 color family.

Formats

format picks the notation, out of the four in formats. Switching it rewrites the value, not the color: the same color 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 color 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 with no canvas and no rails is a palette picker — the right shape wherever the answer has to stay on brand. It works inside ColorPicker.Popup, inside ColorPicker.Panel, or bare, with neither around it. colors takes any strings value accepts, and columns sets both the grid and the arrow-key stride. Set the prop, not --forte-color-picker-swatch-columns: the component writes that variable inline from columns, so overriding it through style redraws the grid but leaves Up and Down stepping by the old row length.

color-picker/swatches.tsx

Bare, the parts go in a layout of your own. Here the palette sits beside a text field in a row that wraps — the shape of a color question in a form. A flex row sizes the grid by its content, so the swatches stay at their size. Alone in a block, the grid stretches to the block's width the way it fills a panel's, and its swatches grow with it; give it a width there.

color-picker/bare-swatches.tsx

The colors 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 color 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 color 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 color in a visually hidden span — so a trigger with no children is still named, by the value itself.

color-picker/trigger.tsx

Give it render to draw it as something else. The built-in trigger styling steps aside, so the rendered component keeps its own look; the swatch, the hidden color announcement and the live color variables stay, which is what still makes it a color trigger. Inside a toolbar that something is a Toolbar.Button, which brings the bar's quiet styling, its size, and a place in its arrow-key order.

color-picker/toolbar.tsx

In a form

ColorPicker.HiddenInput submits the color 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
KeyBehavior
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 color 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 color 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 color palette costs a keyboard user one Tab instead of twenty-four.

Two further notes:

  • Color 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 color 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 colors, the canvas, the rails, the swatches and the previews opt out of substitution (.forte-preserve-color) and take a system-colored hairline as their boundary — because in this one component the color 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 color space, not a line of text, and the hue on the left of every other color 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. A part used without a panel falls back to the same defaults, and takes a knob set on itself or on any element around it.

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 canvasmin(var(--forte-radius-control), var(--forte-radius-5))
--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 color 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 color8px
--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 color, alpha included
--forte-color-picker-solidthe same color 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 color 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 color 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 color 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 color, as a CSS color 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 colors (`rebeccapurple`) are not accepted — see the docs page.

ColorPicker.Trigger

Props for ColorPickerTrigger
PropTypeDefaultDescription
childrenReactNodeThe trigger's visible label. The current color 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 color some other way — a filled button, an icon that inherits it.
nativeButtonbooleantrueWhether the rendered element is a real `<button>`. Set it to `false` when `render` replaces the button with something else (a `<div>`, a table cell), so Base UI supplies the keyboard and role behavior the element does not have natively.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, PopoverTriggerState>Replaces the rendered `<button>` with another element or component. This is how the trigger takes on a look that is not its own — `render={<Button variant="outline" iconOnly />}`, or `render={<Toolbar.Button iconOnly />}` for a swatch in a toolbar, which also puts it in the bar's arrow-key order. The built-in trigger styling steps aside when this is present, so the two never fight over the cascade. The swatch, the hidden color announcement and the live color variables stay: they are what makes it a color trigger, whatever it is drawn as.

Every other <button> prop passes through unchanged, as do Popover.Trigger's render and nativeButton.

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-center (`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 color strings. Anything `value` accepts works here, including translucent colors.
columnsnumber8How many swatches per row. Also the arrow-key stride, so Up and Down move between rows rather than by one.
labelstringColor swatchesAccessible name for the group.

ColorPicker.Swatch

Props for ColorPickerSwatch
PropTypeDefaultDescription
value*stringThe color this swatch sets, as a CSS color 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 color 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`.
labelstringColor 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`.
labelstringColor 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 color from the screenAccessible name for the button.

ColorPicker.HiddenInput

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