Color Picker
Figma-style color picker — 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.
Playground
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. Named colors ("red") are rejected — parseColorString returns
null for them, matching React Aria's contract. 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
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.
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
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" | 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" | 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" | "ltr" | Chrome direction (color geometry stays LTR) |
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" | Trigger label when no value renders |
label | string | "Color" | 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" | "field" | Field-like trigger, or the bare color well |
tone | "default" | "on-color" | "default" | Legible on tinted/dark plates |
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.
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" | Group name for assistive tech |
name | string | — | Hidden input for native forms |
disabled / readOnly | boolean | false | States |
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 |
Skeletons
ColorPickerSkeleton mirrors the closed trigger (size, rounded,
triggerVariant, triggerSize). 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 |
- 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
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 |
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 (React Aria mirrors 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; inputs and thumbs expand to 44px touch targets on small screens (WCAG 2.5.5). - Forms — the hidden input lives on the trigger side of the popover variant, so the value submits while the popup is closed.