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/uiWhen 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
showAlphais on. - Colour that must meet contrast.
contrastCheckshows 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.
namesubmits the hex through a hidden input anderrorMessageshows 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.
valueaccepts hex,rgb(),hsl()andhsb()only; names such as"red"are rejected.
Installation
pnpm add @tessinaui/uiUsage
import { ColorPicker, ColorPickerPanel, ColorSwatchPicker } from "@tessinaui/ui";{/* 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
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value — hex, rgb(), hsl() or hsb() string |
defaultValue | string | "#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 |
showAlpha | boolean | true | Alpha rail + opacity input |
showEyedropper | boolean | true | Eyedropper button (auto-hidden without browser support) |
showInputs | boolean | true | Model select + channel inputs row |
showArea | boolean | true | SV canvas; false is the sliders-only panel |
defaultModel | "hex" | "rgb" | "hsl" | "hsb" | "hex" | Initial input model |
model / onModelChange | — | — | Controlled input model |
label | string | — | Visible heading; names the panel group |
showPreview | boolean | false | Original-vs-current bar; the original half restores on click |
name | string | — | Hidden input carrying the current hex for native forms |
required | boolean | false | Stars the label; validation is the consumer's |
readOnly | boolean | false | Focusable and announced, but the value cannot change |
swatches | ColorSwatchItem[] | — | Quick swatches under the inputs |
swatchesLabel | string | "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 / defaultRecentColors | string[] | — | Recent-colors row (auto-appended on commit, deduped, capped at 8) |
onRecentColorsChange | (colors: string[]) => void | — | Fires with the updated recents |
recentColorsLabel | string | "Recent", Arabic under dir="rtl" | Heading above the recents |
libraries | ColorLibrary[] | — | Named groups — presence renders the Custom | Libraries tabs |
contrastCheck | ContrastCheckOptions | — | 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) |
disabled | boolean | false | Disables all interaction |
dir | "ltr" | "rtl" | — | Chrome direction (color geometry stays LTR). Unset, it inherits the page's direction |
strings | Partial<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:
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "Pick a color", Arabic under dir="rtl" | Trigger label when no value renders |
label | string | "Color", Arabic under dir="rtl" | Stable accessible trigger name (leads the announced value) + sheet title |
triggerVariant | ButtonProps["variant"] | "outline" | Trigger button variant |
triggerSize | ButtonProps["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" | inherited | Legible 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 |
errorMessage | string | — | Message under the trigger; implies intent="error" |
closeOnSelect | boolean | false | Dismiss the popup on swatch pick |
mobilePresentation | "sheet" | "popover" | "sheet" | Bottom sheet below md, or always the popover |
side / align | positioning | "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
| Prop | Type | Default | Description |
|---|---|---|---|
items | ColorSwatchItem[] | — | The palette |
value / defaultValue | string | — | Selected hex; "" means none |
onValueChange | (value: string) => void | — | Selection change |
layout | "grid" | "row" | "grid" | Wrapping grid or one scrollable line |
columns | number | 8 | Grid 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 |
allowNone | boolean | false | Prepends the no-color slash tile |
onCustomClick | () => void | — | Renders the trailing rainbow custom tile |
label | string | "Color swatches", Arabic under dir="rtl" | Group name for assistive tech |
strings | Partial<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 |
name | string | — | Hidden input for native forms |
disabled / readOnly | boolean | false | States |
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
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | — | Hex color of the tile |
name | string | — | Accessible name (announced instead of the hex) |
selected | boolean | false | Selection 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
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
| Key | Where | Action |
|---|---|---|
Tab / Shift+Tab | everywhere | Move between the canvas, rails, inputs and swatch groups |
| Arrows | canvas | ±1% saturation (←→) / brightness (↑↓); Shift ±10% |
| Arrows | rails | ±1 step; Shift ±10; hue wraps 359° → 0° |
PageUp / PageDown | canvas, rails | Page step — brightness 10%, hue 15°, opacity 10% |
Home / End | canvas, rails | Jump to the extremes (hue pins 0° / 360°, no wrap) |
Arrows / Home / End | swatch groups | Move between tiles (grid rows move by columns) |
Enter / Space | swatch tiles | Select |
Esc | popover / sheet | Close and return focus to the trigger |
Tab | ink row | Chip → hex → opacity — the chip is the only control that opens the picker |
Enter / Space | ink chip | Open the picker |
Enter | ink hex | Commit (blur commits too); invalid or emptied text reverts |
Esc | ink hex | Revert 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 isbutton "Fill: #711DC8"withhaspopup="dialog", the fields areFill hexandFill 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.
readOnlykeeps every control focusable and announced witharia-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:
| Surface | Announced as |
|---|---|
| Trigger | button "Brand color (required): #711DC8", haspopup="dialog", expandable |
| Error | trigger gains description=; the message is a polite live region; the trigger node survives the error appearing/clearing (no focus loss) |
| Sheet | dialog "Brand color" + one level-2 heading — the panel does not repeat the title |
| Sliders | named Color / Hue / Opacity, each with aria-valuetext ("Saturation 86%, Brightness 78%", "Hue 269°", "100% opacity") |
| Swatches | group "Project color" → button "Purple" pressed, described by its hex — the name is never repeated |
| Read-only | text fields readonly, sliders aria-readonly and still focusable, swatches announce as unavailable (aria-disabled — aria-readonly is illegal on role="button") |
| Contrast | one polite region: "Contrast ratio 7.74 to 1, passes AA", announced on settle |
| Ink chip | button "Fill: #711DC8", haspopup="dialog", collapsed; with errorMessage the message is its description |
| Ink fields | textbox "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:
smcontrols in themdpanel,xsin thesmpanel. - 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
EyeDropperAPI (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.