DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
FieldFieldsetFormFile UploadColor PickerMediaTrimmer
AI agentsContributing
  1. Components
  2. Color Picker

Color Picker

Colour picker for design tools — saturation/value canvas, hue and alpha sliders, eyedropper, HEX/RGB/HSL/HSB inputs, swatch libraries, recents, a standalone swatch picker, and a WCAG contrast checker with live boundary curves. Form-ready, sheet presentation on phones, light/dark, RTL.

$ pnpm add @tessinaui/ui

When to use

  • An exact colour. Brand and theme settings, and design tools, where people need a precise value and not a nearby preset.
  • Several input models. HEX, RGB, HSL and HSB are all editable, with an opacity rail when showAlpha is on.
  • Colour that must meet contrast. contrastCheck shows the live ratio and the boundary curve while people pick.
  • Presets first, custom second. Start with a swatch set and let a custom tile escalate to the full panel (Progressive disclosure).
  • A colour field in a form. name submits the hex through a hidden input and errorMessage shows the error state.

When not to use

  • For a few fixed colours. Use ColorSwatchPicker, which needs no canvas, or Radio or SegmentedControl when the choice is a handful of named options.
  • For a plain hex value with no visual picking. Use Field.
  • When colour is not the user's task. On a dense page keep the trigger form and let the popup carry the panel, rather than placing the panel inline.
  • For named CSS colours. value accepts hex, rgb(), hsl() and hsb() only; names such as "red" are rejected.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { ColorPicker, ColorPickerPanel, ColorSwatchPicker } from "@tessinaui/ui";
example.tsx
{/* Inline panel */}
<ColorPickerPanel defaultValue="#711DC8" onValueChange={setColor} />

{/* Button-triggered popover (bottom sheet below md) */}
<ColorPicker value={color} onValueChange={setColor} label="Brand color" />

{/* Preset-palette picker — no canvas at all */}
<ColorSwatchPicker items={palette} value={color} onValueChange={setColor} />

value accepts hex (#RRGGBB/#RRGGBBAA) plus rgb(), hsl() and hsb() CSS strings. Parsing stays limited to those numeric syntaxes: named colors ("red") are rejected, and parseColorString returns null for them. Emitted values are always uppercase hex.

Examples

Default

The popover trigger with quick swatches.

Commit vs live changes

onValueChange fires per drag frame; onValueCommit fires once per finished interaction — with the original-vs-current preview bar.

Color models

HEX, RGB, HSL and HSB, controlled via model + onModelChange.

In a form

name submits the hex through a hidden input, label names the trigger and panel, required stars it, and errorMessage swaps in with the error ring.

Read-only and disabled

On color

The swatch pickers inherit the tone too: the selected tile is a gapless border in the plate's ink, and focus opens a gap of the plate around it.

Mobile sheet

Below md the popup presents as a bottom sheet; resize to see the switch.

Swatch-only picker

ColorSwatchPicker — grid and row layouts, a none tile, check indicators, and one-Tab-stop keyboard navigation.

Progressive disclosure

Presets first; the rainbow tile escalates to the full panel.

Recents and saved swatches

The recent-colors row tracks commits; the "+" tile saves into a consumer-owned palette.

Sliders only

showArea={false} — the compact panel.

Trigger variants

The field-like trigger and the bare triggerMode="swatch" color well.

Ink selector

triggerMode="ink" is a fill row: one field well holding the colour chip, the hex and the opacity. Type straight into the row — the hex commits on Enter or blur, the opacity on blur or an arrow step — and only the chip opens the picker. Recent colours are tracked by default in this mode; pass recentColors={[]} to opt out.

Ink states

Read-only, disabled, error, a circle chip through rounded="full", on-color inside a Surface, and the skeleton.

Libraries

Named swatch groups render in a second tab.

Contrast check

The badge shows the live ratio; the solid boundary marks the target threshold and showBothBoundaries adds the other level dashed.

Controlled trigger

RTL

dir="rtl" mirrors the chrome: trigger, panel heading, swatch row, input row and popup alignment. The colour geometry does not mirror. The saturation area and hue rail keep their cross-tool orientation, and hex values stay left-to-right. Unset, dir is inherited from the page, but the built-in labels stay English: only an explicit dir="rtl" defaults them to Arabic.

API Reference

ColorPickerPanel

PropTypeDefaultDescription
valuestring—Controlled value — hex, rgb(), hsl() or hsb() string
defaultValuestring"#711DC8"Uncontrolled initial value
onValueChange(value: string) => void—Every change (drag frames included); 8-digit hex only when showAlpha and alpha < 1
onValueCommit(value: string) => void—Once per finished interaction: release, keyboard step, input commit, swatch/eyedropper pick
showAlphabooleantrueAlpha rail + opacity input
showEyedropperbooleantrueEyedropper button (auto-hidden without browser support)
showInputsbooleantrueModel select + channel inputs row
showAreabooleantrueSV canvas; false is the sliders-only panel
defaultModel"hex" | "rgb" | "hsl" | "hsb""hex"Initial input model
model / onModelChange——Controlled input model
labelstring—Visible heading; names the panel group
showPreviewbooleanfalseOriginal-vs-current bar; the original half restores on click
namestring—Hidden input carrying the current hex for native forms
requiredbooleanfalseStars the label; validation is the consumer's
readOnlybooleanfalseFocusable and announced, but the value cannot change
swatchesColorSwatchItem[]—Quick swatches under the inputs
swatchesLabelstring"Swatches", Arabic under dir="rtl"Heading above the quick swatches
indicator"ring" | "check""ring"Selected-swatch marker
onSwatchAdd(value: string) => void—Renders the "+" save tile
recentColors / defaultRecentColorsstring[]—Recent-colors row (auto-appended on commit, deduped, capped at 8)
onRecentColorsChange(colors: string[]) => void—Fires with the updated recents
recentColorsLabelstring"Recent", Arabic under dir="rtl"Heading above the recents
librariesColorLibrary[]—Named groups — presence renders the Custom | Libraries tabs
contrastCheckContrastCheckOptions—Ratio badge + boundary curve(s)
size"sm" | "md""md"Panel width — 280px / 320px
rounded"none" | "sm" | "md" | "lg" | "full""md"Corner radius (cascades to every internal control)
disabledbooleanfalseDisables all interaction
dir"ltr" | "rtl"—Chrome direction (color geometry stays LTR). Unset, it inherits the page's direction
stringsPartial<ColorPickerStrings>English, Arabic under dir="rtl"Override any built-in label: the preview, eyedropper and close buttons, the tabs, channel names, slider values, the contrast announcement, the ink fields, the trigger's placeholder and fallback names (chooseColor, color), the required suffix, and the swatchesLabel and recentColorsLabel headings. {placeholders} such as {hex} are filled in
onClose() => void—Presence renders the × header button

ColorPicker (popover trigger)

Extends the panel's functional props, plus:

PropTypeDefaultDescription
placeholderstring"Pick a color", Arabic under dir="rtl"Trigger label when no value renders
labelstring"Color", Arabic under dir="rtl"Stable accessible trigger name (leads the announced value) + sheet title
triggerVariantButtonProps["variant"]"outline"Trigger button variant
triggerSizeButtonProps["size"]"sm"Trigger button size
triggerMode"field" | "swatch" | "ink""field"Field-like trigger, the bare color well, or the ink row (chip + editable hex + opacity in one well)
tone"default" | "on-color"inheritedLegible on tinted/dark plates. Inherited from a wrapping <Surface>, else "default"; an explicit prop wins
intent"none" | "error" | "warning" | "success" | "info""none"Semantic border + ring on the trigger
errorMessagestring—Message under the trigger; implies intent="error"
closeOnSelectbooleanfalseDismiss the popup on swatch pick
mobilePresentation"sheet" | "popover""sheet"Bottom sheet below md, or always the popover
side / alignpositioning"bottom" / "start"Popover placement
open / defaultOpen / onOpenChange——Popover open state

ref and forwarded HTML attributes (data-*, handlers) land on the TRIGGER — the persistent element; the panel unmounts while closed. With triggerMode="ink" they land on the colour CHIP, the only popup control, and triggerClassName styles the well around the chip, hex and opacity. The popup hangs from the whole well.

Recents are owned by ColorPicker itself, so uncontrolled defaultRecentColors survive the popover closing and reopening.

ColorSwatchPicker

PropTypeDefaultDescription
itemsColorSwatchItem[]—The palette
value / defaultValuestring—Selected hex; "" means none
onValueChange(value: string) => void—Selection change
layout"grid" | "row""grid"Wrapping grid or one scrollable line
columnsnumber8Grid column count
size"sm" | "md" | "lg""md"Tile size — 20 / 24 / 32px
rounded"none" | "sm" | "md" | "lg" | "full""md"Tile radius; full = circles
indicator"ring" | "check""ring"Selected marker
allowNonebooleanfalsePrepends the no-color slash tile
onCustomClick() => void—Renders the trailing rainbow custom tile
labelstring"Color swatches", Arabic under dir="rtl"Group name for assistive tech
stringsPartial<Pick<ColorPickerStrings, "swatchGroup" | "noColor" | "customColor">>English, Arabic under dir="rtl"Override the group's name (swatchGroup), the none tile's (noColor) and the custom tile's (customColor). label, noneLabel and customLabel win
namestring—Hidden input for native forms
disabled / readOnlybooleanfalseStates
tone"default" | "on-color"inherited from <Surface>Colour recipe for a coloured or dark surface: the tiles' focus ring is the plate's ink over a gap of the plate, the selected tile a gapless border in that ink (so focus still shows on it), and the none tile is outlined in its ink

ColorPickerSwatch

PropTypeDefaultDescription
colorstring—Hex color of the tile
namestring—Accessible name (announced instead of the hex)
selectedbooleanfalseSelection marker
indicator"ring" | "check""ring"Marker style
onSelect(value: string) => void—Click handler
size"sm" | "md" | "lg""md"Tile size
rounded"none" | "sm" | "md" | "lg" | "full""md"Tile radius
tone"default" | "on-color"inherited from <Surface>On a coloured or dark surface the focus ring is the plate's ink over a gap of the plate, and the selection a gapless border in that ink

Skeletons

ColorPickerSkeleton mirrors the closed trigger (size, rounded, triggerVariant, triggerSize, and triggerMode="ink" for the field well). ColorPickerPanelSkeleton mirrors the open panel (showArea, preview, recents, showAlpha, showEyedropper, showInputs, swatches, libraries, contrast, closeable, dir). ColorSwatchPickerSkeleton mirrors the swatch picker (items, layout, columns, size, rounded).

Types

example.ts
interface ColorSwatchItem {
  value: string; // "#RRGGBB" | "#RRGGBBAA"
  name?: string; // accessible name
}

interface ColorLibrary {
  name: string; // group heading
  colors: ColorSwatchItem[];
}

interface ContrastCheckOptions {
  against: string;              // comparison color (hex)
  target?: "AA" | "AAA";        // default "AA"
  largeText?: boolean;          // 3.0 / 4.5 thresholds instead of 4.5 / 7.0
  showBothBoundaries?: boolean; // dashed second curve for the other level
}

The color utilities hexToHsva, hsvaToHex, parseColorString, and contrastRatio are also exported for app-side use.

Accessibility

The canvas and rails follow the WAI-ARIA slider pattern (the canvas is a 2D slider announcing both channels through aria-valuetext); swatch sets are roving-tabindex toggle groups — one Tab stop, aria-pressed selection.

Keyboard

KeyWhereAction
Tab / Shift+TabeverywhereMove between the canvas, rails, inputs and swatch groups
Arrowscanvas±1% saturation (←→) / brightness (↑↓); Shift ±10%
Arrowsrails±1 step; Shift ±10; hue wraps 359° → 0°
PageUp / PageDowncanvas, railsPage step — brightness 10%, hue 15°, opacity 10%
Home / Endcanvas, railsJump to the extremes (hue pins 0° / 360°, no wrap)
Arrows / Home / Endswatch groupsMove between tiles (grid rows move by columns)
Enter / Spaceswatch tilesSelect
Escpopover / sheetClose and return focus to the trigger
Tabink rowChip → hex → opacity — the chip is the only control that opens the picker
Enter / Spaceink chipOpen the picker
Enterink hexCommit (blur commits too); invalid or emptied text reverts
Escink hexRevert an unfinished edit; on a clean field it passes through
↑ / ↓ink opacity±1%; Shift ±10%; each step commits
  • The ink row names its parts from label: the chip is button "Fill: #711DC8" with haspopup="dialog", the fields are Fill hex and Fill opacity, and the # before the digits is hidden from assistive tech. Focusing or typing in a field never opens the picker.
  • The contrast badge announces its ratio after the value settles (~150ms), never per drag frame.
  • readOnly keeps every control focusable and announced with aria-readonly="true".
  • The trigger's accessible name leads with label ("Color: #711DC8"), so multiple pickers stay distinguishable.

AT matrix

VoiceOver + Safari: passed 2026-09-14. The trigger reads "Color: #4D37F5, dialog pop up button". Space opens the sheet on "Custom, selected tab, 1 of 2"; Tab reaches "Close color picker, button"; Escape returns to the trigger. NVDA and JAWS are untested.

Accessibility tree (Chromium, computed names/roles/states): verified across every surface — trigger, error state, sheet, sliders, swatch groups, read-only and the contrast announcer. What that pass established:

SurfaceAnnounced as
Triggerbutton "Brand color (required): #711DC8", haspopup="dialog", expandable
Errortrigger gains description=; the message is a polite live region; the trigger node survives the error appearing/clearing (no focus loss)
Sheetdialog "Brand color" + one level-2 heading — the panel does not repeat the title
Slidersnamed Color / Hue / Opacity, each with aria-valuetext ("Saturation 86%, Brightness 78%", "Hue 269°", "100% opacity")
Swatchesgroup "Project color" → button "Purple" pressed, described by its hex — the name is never repeated
Read-onlytext fields readonly, sliders aria-readonly and still focusable, swatches announce as unavailable (aria-disabled — aria-readonly is illegal on role="button")
Contrastone polite region: "Contrast ratio 7.74 to 1, passes AA", announced on settle
Ink chipbutton "Fill: #711DC8", haspopup="dialog", collapsed; with errorMessage the message is its description
Ink fieldstextbox "Fill hex" reading the digits only (the # is hidden) and Fill opacity number field; the error hex adds its description and invalid

VoiceOver + Chrome, ink row (2026-09-14): spoken output captured for the chip, hex and opacity, in the resting and error states — names are never repeated. The VoiceOver + Safari keyboard walk for the ink row is not yet recorded.

VoiceOver listening: NOT yet recorded. The tree pass above verifies what is announced, not how it sounds in sequence — only a human (or a scripted VoiceOver session) can confirm that. NVDA and JAWS: UNTESTED — no Windows host exists for this project. Both gaps keep the badge at beta (QA flow §5.6).

Notes

  • Anatomy (2026-09-03 redesign) — the SV canvas is a full-width SQUARE (equal travel per pixel on both axes; macOS's NSColorPanel ships the same 1:1), the value inputs are ONE bordered group split by hairline seams (model select · value · opacity, with the % inside the box), the eyedropper is an outlined square beside the rails, and the swatch heading carries the "+ Add" action at its end. The row runs on the input family's own control scale: sm controls in the md panel, xs in the sm panel.
  • Value model — external strings in, uppercase hex out; the picker keeps an internal HSVA state so hue survives passing through black, white, and gray. HSB inputs map 1:1 onto that state.
  • RTL & color geometry — panel chrome mirrors with dir="rtl"; the canvas and rails deliberately stay LTR (some libraries mirror them instead — recorded divergence: color geometry is a cross-tool convention).
  • Contrast checking — translucent colors are measured as rendered, composited over the comparison color. Boundary curves are recomputed per hue via binary search on the WCAG luminance, which is monotonic in brightness.
  • Browser support — the eyedropper uses the native EyeDropper API (Chromium only) and hides itself elsewhere.
  • Mobile — the popup becomes a swipe-dismissable bottom sheet below md. On a touch screen, at any width, every control has a whole 44px target (WCAG 2.5.5): inputs, thumbs, the ink chip and the eyedropper, and swatch tiles, which space themselves so neighbouring targets meet.
  • Forms — the hidden input lives on the trigger side of the popover variant, so the value submits while the popup is closed.
File UploadMediaTrimmer

On this page

When to useWhen not to useInstallationUsageExamplesDefaultCommit vs live changesColor modelsIn a formRead-only and disabledOn colorMobile sheetSwatch-only pickerProgressive disclosureRecents and saved swatchesSliders onlyTrigger variantsInk selectorInk statesLibrariesContrast checkControlled triggerRTLAPI ReferenceColorPickerPanelColorPicker (popover trigger)ColorSwatchPickerColorPickerSwatchSkeletonsTypesAccessibilityKeyboardAT matrixNotes