ColorPicker
Introduction
A colour picker with a saturation/brightness area, hue and alpha sliders, editable channel inputs (HEX / RGBA / HSLA), preset swatches, and an optional swatch trigger that opens the panel in a popover. It follows the Ark UI / Park UI colour-picker anatomy — every part carries data-scope="colorPicker" and a matching data-part — but is implemented entirely on Hono JSX and Panda CSS, with no React and no @ark-ui/react dependency.
The component is split the same way as the rest of the library:
app/components/ui/color-picker-primitive.tsx— colour math, context, and every anatomical part, all server-renderable.app/islands/color-picker.tsx— thin island wrapper aroundInteractiveColorPicker, which owns state and attaches pointer/keyboard handlers.app/theme/recipes/color-picker.ts— the Panda CSS slot recipe.
Colours are held internally as HSVA ({ h: 0–360, s: 0–100, v: 0–100, a: 0–1 }), the natural model for a saturation/brightness area, and converted at the edges.
What's new in this revision
- HSLA output is real HSL.
hsvaToHslaStringpreviously stamped raw HSV saturation/value numbers into anhsla()string, producing a different colour than the one selected (e.g. pure red rendered ashsla(0, 100%, 100%, 1)— white). It now converts HSV → HSL properly (hsla(0, 100%, 50%, 1)). - The HSLA input row edits true HSL channels. The saturation/lightness inputs used to display HSV values under HSL labels; edits and displayed values now agree, and a dedicated
lchannel maps lightness edits back into the HSV model. - Invalid hex input is rejected instead of committed. Typing garbage into the hex field previously fell back to white and emitted it as a value change. The field now validates
#RGB,#RGBA,#RRGGBB, and#RRGGBBAA(with or without the leading#) and ignores anything else. - Format select is labelled for assistive technology.
Keyboard support
The area and both sliders are focusable (Tab) and keyboard-operable:
| Key | Area | Hue slider | Alpha slider |
|---|---|---|---|
| ← / → | Saturation ±1 | Hue ±1° | Alpha ±1% |
| ↑ / ↓ | Brightness ±1 | — | — |
| Shift + arrows | ±10 steps | ±10° | ±10% |
| Home / End | Min / max corner | 0° / 360° | 0% / 100% |
With trigger, Esc and outside clicks close the popover.
Accessibility
- The area and channel sliders expose
role="slider"witharia-valuemin/aria-valuemax/aria-valuenow(the area additionally reports both channels througharia-valuetext). - Channel inputs, the format select, preset swatches, and the eye-dropper trigger all carry
aria-labels; the active preset swatch is announced viaaria-pressed. disabledandreadOnlyremove the interactive tab stops and disable the swatch buttons; state is mirrored todata-disabled/data-readonlyon every part.- The eye-dropper button renders disabled where the
EyeDropperAPI is unavailable, so it is never a dead control.
Styling slots
The Panda CSS slot recipe (app/theme/recipes/color-picker.ts) styles the parts listed under Anatomy. States are driven by data attributes (data-disabled, data-readonly, data-state="checked" on the active swatch, data-channel on slider parts), so custom skins can target them without touching the recipe. The size variant scales the area, swatches, and spacing.
Usage
import { ColorPicker } from "../components/ui";
export default function MyPage() {
return (
<>
{/* Inline picker, static SSR (no signal, no island) */}
<ColorPicker interactive={false} />
{/* Interactive inline picker */}
<ColorPicker
label="Brand colour"
defaultValue="#3b82f6"
onValueChange={({ value }) => console.log(value)}
/>
{/* Swatch trigger + popover, custom presets */}
<ColorPicker
trigger
label="Accent"
defaultValue="#22c55e"
presets={["#ef4444", "#f97316", "#22c55e", "#3b82f6"]}
closeOnSelect
/>
{/* Inside a native form — submits as `theme` (hex) */}
<form method="post" action="/settings">
<ColorPicker name="theme" defaultValue="#7c3aed" />
<button type="submit">Save</button>
</form>
</>
);
}
CMS Page Builder
This component is available as a colorPicker block in the Page Builder (content/pages/*.json):
{
"type": "colorPicker",
"label": "Brand colour",
"defaultValue": "#3b82f6"
}
Props
<ColorPicker /> (the styled wrapper) accepts:
| Prop | Type | Description |
|---|
| value | `string \ | HSVA` | Current colour (controlled). Strings accept hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba(), and hsl()/hsla(). |
| defaultValue | `string \ | HSVA` | Initial colour (uncontrolled). Defaults to #7c3aed. |
| format | `"hex" \ | "rgba" \ | "hsla"` | Active input format (controlled). |
| defaultFormat | `"hex" \ | "rgba" \ | "hsla"` | Initial input format (uncontrolled). Defaults to "hex". |
| onValueChange | (details: { value: string; hsva: HSVA }) => void | Called on every colour change; value is the hex string (with alpha suffix when translucent). |
| onFormatChange | (details: { format: ColorFormat }) => void | Called when the format select changes. |
| trigger | boolean | Render a swatch trigger that opens the panel in a popover instead of inline. |
| open / defaultOpen | boolean | Popover state (controlled / uncontrolled); only meaningful with trigger. |
| onOpenChange | (details: { open: boolean }) => void | Called when the popover opens or closes. |
| closeOnSelect | boolean | Close the popover after picking a preset swatch. Defaults to false. |
| presets | string[] | Preset swatch colours. Defaults to a curated 14-colour palette; pass [] to hide. |
| name | string | Renders a hidden input carrying the hex value for native <form> submission. |
| label | `string \ | JSX.Element` | Optional label rendered above the picker. |
| showArea / showSliders / showInputs / showSwatches | boolean | Toggle individual panel sections. All default to true. |
| disabled | boolean | Disables all interaction. |
| readOnly | boolean | Value is visible but cannot be changed. |
| size | `"sm" \ | "md" \ | "lg"` | Recipe size variant (area height, swatch size, spacing). Defaults to "md". |
| interactive | boolean | Forces (or suppresses) hydration as an island. |
Hydration
The wrapper auto-hydrates when a behavioural signal is present — any callback, a value/defaultValue, open/defaultOpen, or trigger — and renders static HTML otherwise. interactive={false} always opts out; interactive (or interactive={true}) always opts in. The decision routes through the shared shouldHydrate() helper, like every island in the library.
Colour utilities
Exported from the primitive for reuse and testing:
| Helper | Description |
|---|---|
parseColor(input) | Parses hex/rgb(a)/hsl(a) strings, HSVA-ish or RGB-ish objects into a clamped HSVA. Falls back to white. |
hsvToRgb(h, s, v) / rgbToHsv(r, g, b) | HSV ↔ RGB conversion. |
hsvToHsl(h, s, v) / hslToHsv(h, s, l) | HSV ↔ HSL conversion (s/v/l as 0–100). |
hexToRgb(hex) | Parses 3/4/6/8-digit hex into { r, g, b, a }, or null when malformed. |
hsvaToHex(c, includeAlpha?) | Hex string; appends the alpha byte only when requested and translucent. |
hsvaToRgbaString(c) / hsvaToHslaString(c) | CSS rgba() / hsla() strings. |
Production notes
- No new dependencies. All colour math (HSV/HSL/RGB/hex conversions, parsing) is implemented locally and unit-tested; nothing is pulled from
@rc-component,@ark-ui, or React. - SSR-safe. Every part renders meaningful markup on the server — the static variant is a faithful, non-interactive preview of the exact same DOM the island hydrates.
- Controlled or uncontrolled.
value/format/openeach support both modes with the usualdefault*counterparts. - Precision. Assign
HSVAobjects (fromonValueChange'sdetails.hsva) rather than re-parsed strings in controlled scenarios to avoid round-trip rounding drift between formats. - Form-ready. The
nameprop renders a hidden input with the current hex value, kept in sync on every change.