Base components
Color picker
A small swatch button that opens a panel of 16 color swatches and a hex field, where the panel grows from the button, a ring moves onto the picked swatch, and the button dot eases to the new color.
Live preview
Loading preview
The tool button in a short text editor toolbar row whose text takes the picked color. Picking Purple changes the trigger dot; typing 7F56D9 in Custom picks a custom color. Replay opens the panel and picks Blue.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add color-pickerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { TooltipProvider } from '../tooltip/tooltip';
import { ColorPicker } from './color-picker';
const TOOLS = [
{
key: 'bold',
label: 'Bold',
path: 'M5 3.333h5.833a3.333 3.333 0 0 1 0 6.667H5V3.333Zm0 6.667h6.667a3.333 3.333 0 0 1 0 6.667H5V10Z',
},
{ key: 'italic', label: 'Italic', path: 'M15.833 3.333H8.333M11.667 16.667H4.167M12.5 3.333 7.5 16.667' },
] as const;
const toolClass =
'inline-flex size-8 cursor-pointer items-center justify-center rounded-sm text-fg-quaternary outline-none transition-colors duration-sm ease-out hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:shadow-focus-ring aria-pressed:bg-secondary_hover aria-pressed:text-fg-secondary motion-reduce:transition-none';
export default function ColorPickerDemo({ replay = 0 }: { replay?: number }) {
const [color, setColor] = React.useState('#9333EA');
const [open, setOpen] = React.useState(false);
const [pressed, setPressed] = React.useState<Record<string, boolean>>({ bold: true });
React.useEffect(() => {
if (replay === 0) return;
setColor('#9333EA');
setOpen(true);
const timers = [
window.setTimeout(() => setColor('#2563EB'), 700),
window.setTimeout(() => setOpen(false), 1300),
];
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<TooltipProvider>
<div className="w-full max-w-sm overflow-hidden rounded-xl border border-secondary bg-primary shadow-xs">
<div className="flex items-center gap-1 border-b border-secondary px-2 py-1.5">
{TOOLS.map((tool) => (
<button
key={tool.key}
type="button"
aria-label={tool.label}
aria-pressed={Boolean(pressed[tool.key])}
className={toolClass}
onClick={() => setPressed((p) => ({ ...p, [tool.key]: !p[tool.key] }))}
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
className="size-5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={tool.path} strokeWidth={1.67} vectorEffect="non-scaling-stroke" />
</svg>
</button>
))}
<span aria-hidden="true" className="mx-1 h-5 w-px bg-(--lunia-border-secondary)" />
<ColorPicker value={color} onValueChange={setColor} open={open} onOpenChange={setOpen} />
</div>
<p
className="px-4 py-3 text-md text-primary transition-colors duration-sm ease-out motion-reduce:transition-none"
style={{
color,
fontWeight: pressed.bold ? 600 : 400,
fontStyle: pressed.italic ? 'italic' : 'normal',
}}
>
Release notes for the spring update.
</p>
</div>
</TooltipProvider>
);
}
API
Parts: trigger (32px tool button with a 16px color dot; a ring when open), panel (popover), swatch grid (2 rows of 8), swatch (16px dot, 24px ring when picked), custom row (label "Custom", hex field with a color dot).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | Picked color (controlled) as a 6-digit hex string with #, for example "#7F56D9". |
| defaultValue | string | the first swatch | Picked color when uncontrolled. The first default swatch is Neutral 900, read from the tokens. |
| swatches | Array<{ value: string; label: string }> | the 16 Lunia swatches | Colors in the grid, 8 per row. Each label is the color name read by screen readers. |
| custom | boolean | true | Shows the Custom row with the hex field. |
| label | string | "Text color" | Accessible name of the trigger and the panel. |
| open | boolean | undefined | Panel open state (controlled). |
| defaultOpen | boolean | false | Panel open state when uncontrolled. |
| side | "top" | "bottom" | "bottom" | Side of the trigger where the panel opens; it flips when there is no room. |
| disabled | boolean | false | Turns the trigger off (50% opacity). |
| Event | Description |
|---|---|
| valueChange | A swatch was picked, or a valid hex value was entered in the Custom field (on Enter or blur). |
| openChange | The panel opened or closed. |
| Slot | Description |
|---|---|
| trigger | Replaces the default tool button, for example to use the picker outside a toolbar. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| CPK1 | hover the trigger | trigger | background-color | transparent → bg-primary_hover | duration-sm (200)ease-out | Swaps at once |
| CPK2 | keyboard focus on the trigger, a swatch or the hex field | focused element | box-shadow | none → focus ring (2px bg-primary gap, 4px focus-ring) | duration-2xs (120)ease-out | Shows at once |
| CPK3 | open | panel | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| CPK4 | open | panel (transform origin at the trigger) | scale, translate toward the trigger | scale-press, -distance-rise → 1, 0 | duration-md (260)ease-settle | Shows at once |
| CPK5 | open | swatch grid and custom row | opacity | 0 → 1 | duration-sm (200)ease-outdelay 60ms | Shows at once |
| CPK6 | close (pick, Escape, outside click) | panel and its content | opacity, scale | 1, 1 → 0, scale-press | duration-xs (160)ease-exit | Hides at once |
| CPK7 | open -> true / false | trigger ring | opacity | 0 → 1 | duration-sm (200)ease-out | Swaps at once |
| CPK8 | hover a swatch | swatch ring | opacity, border-color | 0, transparent → 1, border-primary | duration-sm (200)ease-out | Swaps at once |
| CPK9 | a swatch is picked | picked ring (old swatch out, new swatch in) | opacity, scale | 0, scale-swap → 1, 1 (the old ring fades out in duration-2xs) | duration-sm (200)ease-out | Swaps at once |
| CPK10 | the value changes | trigger dot and hex field dot | background-color | old color → new color | duration-sm (200)ease-out | Swaps at once |
| CPK11 | the hex field is left with a value that is not a color | hex field border and hint | border-color, opacity | border-primary, 0 → border-error, 1 | duration-sm (200)ease-out | Swaps at once |
Accessibility
- The trigger is a button with aria-haspopup dialog and aria-expanded, named by label plus the color name (for example "Text color: Purple"); the color dot is aria-hidden.
- The panel is a non-modal popover named by label. Focus moves to the picked swatch when it opens and returns to the trigger when it closes; Escape closes it.
- The swatch grid is a radio group: the arrow keys move between swatches (Up and Down move by a row), Space or Enter picks and closes. Each swatch is a radio named by its color label, never by the hex code alone.
- The hex field is a text input labelled "Hex color" with a visible "Custom" label; a wrong value shows "Enter a hex code, for example 7F56D9." and keeps the old color.
- The white swatch has a 1px border-primary ring so it stays visible on the white panel (WCAG 1.4.11).
- Swatches are 16px; each has a transparent 24 x 24px hit area (WCAG 2.5.8), which the 8px gap allows.
- Reduced motion: the panel shows and hides at once, rings and colors swap at once.