Color picker
Choose a color from named swatches, an editor with area, hue, eyedropper and HEX, RGB or HSL, or turn one color into a scale with contrast ratios.
One of 7 components in Pickers and selections
Variants
Details
What Color picker brings along, read from the registry and the source.
- Category
- Pickers and selections
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/color-picker.tsx
- Brings along
- utilskiticoninputpopovercopy-button
- npm packages
- None
- Languages
- English and German, 21 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ColorPickerProps interface from components/ui/color-picker.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
variantoptionalColorPickerVariantswatchespopoverinlinepalette
valueoptionalstringThe color as #RRGGBB
defaultValueoptionalstringonValueChangeoptional(hex: string) => voidswatchesoptional(string | ColorSwatch)[]Preset colors, as hex strings or { value, label }
allowCustomoptionalbooleanswatches: offer a custom color through the system picker
formatsoptional('hex' | 'rgb' | 'hsl')[]inline: show the formats HEX, RGB and HSL
triggerLabeloptionalstringpopover: the trigger text, e.g. "Brand color"
stepsoptionalnumber[]palette: steps of the scale
defaultOpenoptionalbooleanOpen on first render, for demos
idoptionalstringnameoptionalstringdisabledoptionalboolean
Built-in texts
Color picker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 21 texts
| Key | English | German |
|---|---|---|
| choose | English Choose a color | German Farbe wählen |
| custom | English Custom color | German Eigene Farbe |
| hex | English Hex | German Hex |
| area | English Saturation and brightness | German Sättigung und Helligkeit |
| saturation | English Saturation | German Sättigung |
| brightness | English Brightness | German Helligkeit |
| hue | English Hue | German Farbton |
| pick | English Pick from screen | German Vom Bildschirm aufnehmen |
| recent | English Recent | German Zuletzt |
| before | English Before | German Vorher |
| after | English New | German Neu |
| format | English Format | German Format |
| copy | English Copy | German Kopieren |
| copied | English Copied | German Kopiert |
| invalid | English Enter a color like #1E6FD9 | German Gib eine Farbe wie #1E6FD9 ein |
| aa | English AA | German AA |
| aaa | English AAA | German AAA |
| large | English Large text | German Großer Text |
| fail | English Too low | German Zu gering |
| white | English White text | German Weiße Schrift |
| black | English Black text | German Schwarze Schrift |
Accessibility
Read from the source of the component.
- Color picker handles the keys Escape, Enter, Home, End and Space itself.
- Sets aria-checked, aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles dialog, radio, radiogroup and slider.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 653 lines
'use client';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { controlBox, useControl, withField, type FieldOwnProps } from '@/components/ui/input';
import { Floating, floatingPop, originFor, panelSurface, useDismiss, useMounted, usePresence } from '@/components/ui/popover';
import { useCopy } from '@/components/ui/copy-button';
/*
* Choose a color. Swatches are a radio group, the field for saturation and
* brightness is a two axis slider (arrows move it, Shift moves ten steps),
* hue is a slider, values can be typed as HEX, RGB or HSL. The palette look
* turns one color into a scale with contrast ratios after WCAG. Colors here
* are content, like images, so they are plain values and not theme tokens.
*/
const TEXTS = {
en: {
choose: 'Choose a color', custom: 'Custom color', hex: 'Hex', area: 'Saturation and brightness', saturation: 'Saturation', brightness: 'Brightness', hue: 'Hue',
pick: 'Pick from screen', recent: 'Recent', before: 'Before', after: 'New', format: 'Format', copy: 'Copy', copied: 'Copied', invalid: 'Enter a color like #1E6FD9',
aa: 'AA', aaa: 'AAA', large: 'Large text', fail: 'Too low', white: 'White text', black: 'Black text',
},
de: {
choose: 'Farbe wählen', custom: 'Eigene Farbe', hex: 'Hex', area: 'Sättigung und Helligkeit', saturation: 'Sättigung', brightness: 'Helligkeit', hue: 'Farbton',
pick: 'Vom Bildschirm aufnehmen', recent: 'Zuletzt', before: 'Vorher', after: 'Neu', format: 'Format', copy: 'Kopieren', copied: 'Kopiert', invalid: 'Gib eine Farbe wie #1E6FD9 ein',
aa: 'AA', aaa: 'AAA', large: 'Großer Text', fail: 'Zu gering', white: 'Weiße Schrift', black: 'Schwarze Schrift',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Color math */
/* ------------------------------------------------------------------ */
export interface Hsv { h: number; s: number; v: number }
type Rgb = [number, number, number];
const clamp = (n: number, a = 0, b = 1) => Math.min(b, Math.max(a, n));
export function hexToRgb(hex: string): Rgb | null {
let h = hex.trim().replace(/^#/, '');
if (/^[0-9a-f]{3}$/i.test(h)) h = h.split('').map((c) => c + c).join('');
if (!/^[0-9a-f]{6}$/i.test(h)) return null;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
export const rgbToHex = ([r, g, b]: Rgb) => `#${[r, g, b].map((n) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
export function hsvToRgb({ h, s, v }: Hsv): Rgb {
const f = (n: number) => {
const k = (n + h / 60) % 6;
return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
};
return [f(5) * 255, f(3) * 255, f(1) * 255];
}
export function rgbToHsv([r, g, b]: Rgb): Hsv {
const R = r / 255, G = g / 255, B = b / 255;
const max = Math.max(R, G, B), min = Math.min(R, G, B), d = max - min;
let h = 0;
if (d) h = max === R ? ((G - B) / d) % 6 : max === G ? (B - R) / d + 2 : (R - G) / d + 4;
return { h: (h * 60 + 360) % 360, s: max ? d / max : 0, v: max };
}
function rgbToHsl([r, g, b]: Rgb): [number, number, number] {
const R = r / 255, G = g / 255, B = b / 255;
const max = Math.max(R, G, B), min = Math.min(R, G, B), l = (max + min) / 2, d = max - min;
const s = d ? d / (1 - Math.abs(2 * l - 1)) : 0;
const { h } = rgbToHsv([r, g, b]);
return [Math.round(h), Math.round(s * 100), Math.round(l * 100)];
}
function hslToRgb(h: number, s: number, l: number): Rgb {
s /= 100; l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => { const k = (n + h / 30) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
return [f(0) * 255, f(8) * 255, f(4) * 255];
}
/** Relative luminance after WCAG 2 */
export function luminance(hex: string) {
const rgb = hexToRgb(hex) ?? [0, 0, 0];
const c = rgb.map((n) => { const x = n / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; });
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
}
/** Contrast ratio between two colors, 1 to 21 */
export function contrast(a: string, b: string) {
const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
return (x + 0.05) / (y + 0.05);
}
/** Black or white, whichever reads better on the color */
export const readableOn = (hex: string) => (contrast(hex, '#FFFFFF') >= contrast(hex, '#111111') ? '#FFFFFF' : '#111111');
/** Parses #hex, rgb(…) or hsl(…) */
export function parseColor(text: string): string | null {
const t = text.trim();
const hex = hexToRgb(t);
if (hex) return rgbToHex(hex);
const nums = t.match(/-?\d+(\.\d+)?/g)?.map(Number);
if (!nums || nums.length < 3) return null;
if (/^rgb/i.test(t)) return rgbToHex([nums[0], nums[1], nums[2]]);
if (/^hsl/i.test(t)) return rgbToHex(hslToRgb(nums[0], nums[1], nums[2]));
return null;
}
const hexToHsv = (hex: string): Hsv => rgbToHsv(hexToRgb(hex) ?? [0, 0, 0]);
const hsvToHex = (c: Hsv) => rgbToHex(hsvToRgb(c));
/** A scale of tints and shades around a base color, mixed in OKLab-like steps through HSL lightness */
export function colorScale(hex: string, steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]) {
const rgb = hexToRgb(hex) ?? [0, 0, 0];
const [h, s] = rgbToHsl(rgb);
const L: Record<number, number> = { 50: 97, 100: 93, 200: 86, 300: 76, 400: 64, 500: 53, 600: 45, 700: 37, 800: 28, 900: 20 };
return steps.map((step) => ({ step, hex: rgbToHex(hslToRgb(h, clamp(s + (step >= 500 ? 4 : -4), 0, 100), L[step] ?? 50)) }));
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
export interface ColorSwatch {
value: string;
/** A name for screen readers and the tooltip, e.g. "Ocean" */
label?: string;
}
const toSwatch = (s: string | ColorSwatch): ColorSwatch => (typeof s === 'string' ? { value: s } : s);
/** The two axis field: saturation left to right, brightness bottom to top */
function Area({ hsv, onChange, t }: { hsv: Hsv; onChange: (c: Hsv) => void; t: Texts }) {
const ref = useRef<HTMLDivElement>(null);
const pct = useLocale() === 'de' ? ' %' : '%';
const [drag, setDrag] = useState(false);
const at = (e: React.PointerEvent) => {
const r = ref.current!.getBoundingClientRect();
onChange({ ...hsv, s: clamp((e.clientX - r.left) / r.width), v: clamp(1 - (e.clientY - r.top) / r.height) });
};
const key = (e: React.KeyboardEvent) => {
const step = e.shiftKey ? 0.1 : 0.01;
const m: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
if (!m[e.key]) return;
e.preventDefault();
onChange({ ...hsv, s: clamp(hsv.s + m[e.key][0]), v: clamp(hsv.v + m[e.key][1]) });
};
return (
<div
ref={ref}
role="slider"
tabIndex={0}
aria-label={t.area}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(hsv.s * 100)}
aria-valuetext={`${t.saturation} ${Math.round(hsv.s * 100)}${pct}, ${t.brightness} ${Math.round(hsv.v * 100)}${pct}`}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDrag(true);
at(e);
}}
onPointerMove={(e) => drag && at(e)}
onPointerUp={() => setDrag(false)}
onKeyDown={key}
className="group/area relative aspect-[4/3] w-full cursor-crosshair touch-none rounded-lg outline-none focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_70%,transparent)]"
style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${round(hsv.h, 1)} 100% 50%))` }}
>
<span aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)]" />
<span
aria-hidden
className={cn('pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.25),0_2px_6px_rgb(0_0_0/0.3)] transition-[scale] duration-200', drag && 'scale-125')}
style={{ left: `${round(hsv.s * 100, 2)}%`, top: `${round((1 - hsv.v) * 100, 2)}%`, background: hsvToHex(hsv) }}
/>
</div>
);
}
/** A horizontal track with a thumb, used for hue */
function HueSlider({ hue, onChange, t }: { hue: number; onChange: (h: number) => void; t: Texts }) {
const ref = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState(false);
const at = (e: React.PointerEvent) => {
const r = ref.current!.getBoundingClientRect();
onChange(clamp((e.clientX - r.left) / r.width) * 359.9);
};
return (
<div
ref={ref}
role="slider"
tabIndex={0}
aria-label={t.hue}
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hue)}
aria-valuetext={`${Math.round(hue)}°`}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDrag(true);
at(e);
}}
onPointerMove={(e) => drag && at(e)}
onPointerUp={() => setDrag(false)}
onKeyDown={(e) => {
const d = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1 }[e.key];
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
return onChange(e.key === 'Home' ? 0 : 359.9);
}
if (!d) return;
e.preventDefault();
onChange((hue + d * (e.shiftKey ? 15 : 1) + 360) % 360);
}}
className="relative h-3.5 w-full cursor-pointer touch-none rounded-full outline-none before:absolute before:-inset-y-4 before:inset-x-0 sm:before:-inset-y-3 before:content-[''] focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_70%,transparent)]"
style={{ background: 'linear-gradient(to right, #f00, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00)' }}
>
<span
aria-hidden
className={cn('pointer-events-none absolute top-1/2 size-[18px] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.25),0_2px_6px_rgb(0_0_0/0.3)] transition-[scale] duration-200', drag && 'scale-125')}
style={{ left: `calc(9px + (100% - 18px) * ${round(hue / 360, 4)})`, background: `hsl(${round(hue, 1)} 100% 50%)` }}
/>
</div>
);
}
/** A text field for the color in one format. Applies on Enter and blur, marks invalid input. */
function ColorText({ value, onChange, format, t, className }: { value: string; onChange: (hex: string) => void; format: 'hex' | 'rgb' | 'hsl'; t: Texts; className?: string }) {
const show = (hex: string) => {
const rgb = hexToRgb(hex) ?? [0, 0, 0];
if (format === 'rgb') return rgb.map(Math.round).join(', ');
if (format === 'hsl') { const [h, s, l] = rgbToHsl(rgb); return `${h}, ${s}%, ${l}%`; }
return hex.replace('#', '');
};
const [draft, setDraft] = useState<string | null>(null);
const [bad, setBad] = useState(false);
const commit = () => {
if (draft === null) return;
const parsed = parseColor(format === 'hex' ? `#${draft.replace(/^#/, '')}` : `${format}(${draft})`);
if (parsed) { onChange(parsed); setBad(false); } else setBad(true);
setDraft(null);
};
const id = useId();
return (
<div className={cn('grid gap-1', className)}>
<div data-invalid={bad || undefined} className={cn('flex h-11 items-center rounded-md text-base sm:h-9 sm:text-[13px]', controlBox)}>
{format === 'hex' && <span aria-hidden className="ps-2.5 text-subtle-foreground">#</span>}
<input
aria-label={format === 'hex' ? t.hex : format.toUpperCase()}
aria-invalid={bad || undefined}
aria-describedby={bad ? id : undefined}
spellCheck={false}
autoComplete="off"
value={draft ?? show(value)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
className={cn('h-full w-0 min-w-0 flex-1 bg-transparent pe-2.5 tabular-nums text-foreground outline-none', format === 'hex' ? 'ps-1 uppercase' : 'ps-2.5')}
/>
</div>
{bad && <p id={id} className="text-[12px] text-destructive">{t.invalid}</p>}
</div>
);
}
/** A round swatch as a radio */
function Swatch({ swatch, checked, onPick, size = 'md', name, index, focusable }: { swatch: ColorSwatch; checked: boolean; onPick: () => void; size?: 'sm' | 'md'; name: string; index: number; focusable: boolean }) {
return (
<button
type="button"
role="radio"
aria-checked={checked}
aria-label={swatch.label ? `${swatch.label}, ${swatch.value}` : swatch.value}
title={swatch.label ?? swatch.value}
tabIndex={focusable ? 0 : -1}
data-swatch={name}
data-index={index}
onClick={onPick}
className={cn(
'relative grid shrink-0 place-items-center rounded-full outline-none transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-110 active:scale-95 motion-reduce:transition-none',
size === 'sm' ? 'size-11 sm:size-7' : 'size-11 sm:size-9',
// The inner hairline in the text color keeps dark swatches visible on a dark page and light ones on a light page
'shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_16%,transparent)] focus-visible:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_16%,transparent),0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_70%,transparent)]',
checked && 'shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_16%,transparent),0_0_0_2px_var(--background),0_0_0_4px_var(--foreground)]',
)}
style={{ background: swatch.value }}
>
<Icon name="check" size={size === 'sm' ? 14 : 16} strokeWidth={2.4} className={cn('transition-[opacity,scale] duration-200', checked ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} style={{ color: readableOn(swatch.value) }} />
</button>
);
}
/** Swatches as a radio group with roving focus (arrows move and choose) */
function SwatchGroup({ swatches, value, onPick, label, size, className, extra }: { swatches: ColorSwatch[]; value: string; onPick: (hex: string) => void; label: string; size?: 'sm' | 'md'; className?: string; extra?: React.ReactNode }) {
const name = useId();
const current = swatches.findIndex((s) => s.value.toUpperCase() === value.toUpperCase());
const focusAt = current >= 0 ? current : 0;
return (
<div
role="radiogroup"
aria-label={label}
onKeyDown={(e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const i = Number((e.target as HTMLElement).dataset.index ?? focusAt);
const j = (i + d + swatches.length) % swatches.length;
onPick(swatches[j].value);
(e.currentTarget.querySelector(`[data-index="${j}"]`) as HTMLElement | null)?.focus();
}}
className={cn('flex flex-wrap gap-2.5', className)}
>
{swatches.map((s, i) => (
<Swatch key={s.value + i} swatch={s} index={i} name={name} size={size} focusable={i === focusAt} checked={i === current} onPick={() => onPick(s.value)} />
))}
{extra}
</div>
);
}
/* ------------------------------------------------------------------ */
/* ColorPicker */
/* ------------------------------------------------------------------ */
export type ColorPickerVariant = 'swatches' | 'popover' | 'inline' | 'palette';
export interface ColorPickerProps extends FieldOwnProps {
variant?: ColorPickerVariant;
/** The color as #RRGGBB */
value?: string;
defaultValue?: string;
onValueChange?: (hex: string) => void;
/** Preset colors, as hex strings or { value, label } */
swatches?: (string | ColorSwatch)[];
/** swatches: offer a custom color through the system picker */
allowCustom?: boolean;
/** inline: show the formats HEX, RGB and HSL */
formats?: ('hex' | 'rgb' | 'hsl')[];
/** popover: the trigger text, e.g. "Brand color" */
triggerLabel?: string;
/** palette: steps of the scale */
steps?: number[];
/** Open on first render, for demos */
defaultOpen?: boolean;
id?: string;
name?: string;
disabled?: boolean;
labels?: Partial<Texts>;
className?: string;
}
function useColor(value: string | undefined, defaultValue: string | undefined, onValueChange?: (hex: string) => void) {
const [inner, setInner] = useState((defaultValue ?? '#1E6FD9').toUpperCase());
const hex = (value ?? inner).toUpperCase();
// Hue and saturation are kept apart from the hex, so gray and black do not lose them while dragging
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(hex));
useEffect(() => {
if (hsvToHex(hsv) !== hex) setHsv(hexToHsv(hex));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hex]);
const setHex = useCallback(
(next: string) => {
const h = next.toUpperCase();
setInner(h);
setHsv(hexToHsv(h));
onValueChange?.(h);
},
[onValueChange],
);
const setFromHsv = useCallback(
(c: Hsv) => {
setHsv(c);
const h = hsvToHex(c);
setInner(h);
onValueChange?.(h);
},
[onValueChange],
);
return { hex, hsv, setHex, setFromHsv };
}
/** Area, hue, eyedropper and text fields, shared by the popover and the inline look */
function Editor({ c, t, formats, previous, showFormats, compact }: { c: ReturnType<typeof useColor>; t: Texts; formats: ('hex' | 'rgb' | 'hsl')[]; previous?: string; showFormats?: boolean; compact?: boolean }) {
const [format, setFormat] = useState(formats[0]);
const [canPick, setCanPick] = useState(false);
useEffect(() => setCanPick(typeof window !== 'undefined' && 'EyeDropper' in window), []);
const pick = async () => {
try {
const Dropper = (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper;
const res = await new Dropper().open();
const parsed = parseColor(res.sRGBHex);
if (parsed) c.setHex(parsed);
} catch {
/* closed with Escape */
}
};
return (
<div className="grid gap-3.5">
<Area hsv={c.hsv} onChange={c.setFromHsv} t={t} />
<div className="flex items-center gap-3">
{canPick && (
<button type="button" onClick={pick} aria-label={t.pick} title={t.pick} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:size-8">
<Icon name="pipette" size={16} />
</button>
)}
<div className="min-w-0 flex-1">
<HueSlider hue={c.hsv.h} onChange={(h) => c.setFromHsv({ ...c.hsv, h })} t={t} />
</div>
{/* Before and after, side by side */}
{previous && !compact ? (
<span className="flex h-8 w-14 shrink-0 overflow-hidden rounded-md shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)]" aria-hidden>
<span className="flex-1" style={{ background: previous }} title={t.before} />
<span className="flex-1 transition-colors duration-150" style={{ background: c.hex }} title={t.after} />
</span>
) : (
<span aria-hidden className="size-8 shrink-0 rounded-md shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)] transition-colors duration-150" style={{ background: c.hex }} />
)}
</div>
<div className="flex items-start gap-2">
{showFormats && formats.length > 1 && (
<div
role="radiogroup"
aria-label={t.format}
onKeyDown={(e) => {
// Radio group after the APG: arrows move and choose, one tab stop
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = (formats.indexOf(format) + d + formats.length) % formats.length;
setFormat(formats[j]);
const group = e.currentTarget;
requestAnimationFrame(() => group.querySelectorAll<HTMLElement>('[role="radio"]')[j]?.focus());
}}
className="flex h-11 shrink-0 items-center rounded-md bg-muted p-0 sm:h-9 sm:p-0.5"
>
{formats.map((f) => (
<button
key={f}
type="button"
role="radio"
aria-checked={f === format}
tabIndex={f === format ? 0 : -1}
onClick={() => setFormat(f)}
className={cn('h-full min-w-11 rounded-[5px] px-2 text-[11.5px] sm:min-w-0 font-medium uppercase tracking-[0.04em] outline-none transition-[background-color,color,box-shadow] duration-200 focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', f === format ? 'bg-background text-foreground shadow-[var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
>
{f}
</button>
))}
</div>
)}
<ColorText key={format} value={c.hex} onChange={c.setHex} format={format} t={t} className="min-w-0 flex-1" />
</div>
</div>
);
}
function PickerBox(props: Omit<ColorPickerProps, keyof FieldOwnProps> & { fieldLabel?: string }) {
const { variant = 'swatches', value, defaultValue, onValueChange, swatches = [], allowCustom = true, formats = ['hex', 'rgb', 'hsl'], triggerLabel, steps, defaultOpen, id, name, disabled, labels, className, fieldLabel } = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const ctl = useControl({ id, disabled });
const c = useColor(value, defaultValue, onValueChange);
const list = useMemo(() => swatches.map(toSwatch), [swatches]);
const hidden = name ? <input type="hidden" name={name} value={c.hex} /> : null;
const [open, setOpen] = useState(Boolean(defaultOpen));
const [before, setBefore] = useState(c.hex);
const mounted = useMounted();
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const refs = useRef([triggerRef, panelRef]).current;
const close = useCallback(() => setOpen(false), []);
useDismiss(open && variant === 'popover', close, refs);
const [recent, setRecent] = useState<string[]>([]);
const [copyState, copy] = useCopy();
/* ---------- swatches ---------- */
if (variant === 'swatches') {
const custom = list.every((s) => s.value.toUpperCase() !== c.hex);
return (
<div className={cn('grid gap-3', className)}>
<SwatchGroup
swatches={list}
value={c.hex}
onPick={c.setHex}
label={fieldLabel ?? t.choose}
extra={
allowCustom && (
// The system color dialog behind a swatch with a rainbow ring
<label
title={t.custom}
className={cn(
'relative grid size-11 shrink-0 cursor-pointer place-items-center rounded-full p-[3px] transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-110 has-[:focus-visible]:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_70%,transparent)] sm:size-9',
custom && 'shadow-[0_0_0_2px_var(--background),0_0_0_4px_var(--foreground)]',
)}
style={{ background: 'conic-gradient(from 90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)' }}
>
<span className="grid size-full place-items-center rounded-full" style={{ background: custom ? c.hex : 'var(--background)' }}>
{!custom && <Icon name="plus" size={14} strokeWidth={2} className="text-foreground" />}
</span>
<input type="color" aria-label={t.custom} value={c.hex.toLowerCase()} onChange={(e) => c.setHex(e.target.value)} className="absolute inset-0 size-full cursor-pointer opacity-0" />
</label>
)
}
/>
<div className="flex items-center gap-2">
<ColorText value={c.hex} onChange={c.setHex} format="hex" t={t} className="w-32" />
<span className="text-[13px] text-muted-foreground">{list.find((s) => s.value.toUpperCase() === c.hex)?.label ?? t.custom}</span>
</div>
{hidden}
</div>
);
}
/* ---------- palette: one color, a whole scale ---------- */
if (variant === 'palette') {
const scale = colorScale(c.hex, steps);
const fmt = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 1, minimumFractionDigits: 1 });
const grade = (r: number) => (r >= 7 ? t.aaa : r >= 4.5 ? t.aa : r >= 3 ? `${t.aa} ${t.large}` : t.fail);
return (
<div className={cn('grid gap-5', className)}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<HueSlider hue={c.hsv.h} onChange={(h) => c.setFromHsv({ ...c.hsv, h })} t={t} />
<ColorText value={c.hex} onChange={c.setHex} format="hex" t={t} className="sm:w-32" />
</div>
{list.length > 0 && <SwatchGroup swatches={list} value={c.hex} onPick={c.setHex} label={fieldLabel ?? t.choose} size="sm" />}
<ul className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-5 lg:grid-cols-10">
{scale.map((s, i) => {
const fg = readableOn(s.hex);
const r = contrast(s.hex, fg);
return (
<li key={s.step} className="bg-card">
<button
type="button"
onClick={() => copy(s.hex)}
aria-label={`${s.step} ${s.hex}, ${fg === '#FFFFFF' ? t.white : t.black} ${fmt(r)}:1, ${t.copy}`}
className="group/step flex h-full w-full text-left outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_70%,transparent)] max-sm:min-h-14 sm:flex-col"
>
{/* Phones get a row per step, larger screens a column of tiles */}
<span
className="flex w-20 shrink-0 items-center justify-center text-[13px] font-semibold transition-[height,opacity] duration-300 ease-[var(--ease-kit)] starting:opacity-0 motion-reduce:transition-none sm:h-24 sm:w-auto sm:items-end sm:justify-start sm:p-2.5 sm:group-hover/step:h-28"
style={{ background: s.hex, color: fg, transitionDelay: `${i * 30}ms` }}
>
Aa
</span>
<span className="grid flex-1 grid-cols-[3rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2 sm:grid-cols-1 sm:items-start sm:p-2.5">
<span className="text-[12px] font-medium tabular-nums text-foreground">{s.step}</span>
<span className="text-[11.5px] tabular-nums text-muted-foreground">{s.hex}</span>
<span className={cn('text-[11px] tabular-nums', r >= 4.5 ? 'text-success-foreground' : 'text-warning-foreground')}>
{fmt(r)} · {grade(r)}
</span>
</span>
</button>
</li>
);
})}
</ul>
<span aria-live="polite" className="sr-only">{copyState === 'copied' ? t.copied : ''}</span>
{hidden}
</div>
);
}
/* ---------- inline: the full editor on the page ---------- */
if (variant === 'inline') {
return (
<div className={cn('grid gap-4', className)}>
<Editor c={c} t={t} formats={formats} previous={before} showFormats />
{list.length > 0 && <SwatchGroup swatches={list} value={c.hex} onPick={c.setHex} label={fieldLabel ?? t.choose} size="sm" />}
{hidden}
</div>
);
}
/* ---------- popover: a trigger with the editor in a panel ---------- */
return (
<>
<button
ref={triggerRef}
id={ctl.id}
type="button"
disabled={ctl.disabled}
aria-haspopup="dialog"
aria-expanded={open}
aria-describedby={ctl['aria-describedby']}
onClick={() => {
if (!open) setBefore(c.hex);
else if (c.hex !== before) setRecent((r) => [before, ...r.filter((x) => x !== before)].slice(0, 6));
setOpen(!open);
}}
className={cn('inline-flex h-11 items-center gap-2.5 rounded-lg ps-1.5 pe-3 text-sm outline-none sm:h-10', controlBox, 'focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]', className)}
>
<span aria-hidden className="size-8 shrink-0 rounded-md shadow-[inset_0_0_0_1px_rgb(0_0_0/0.12)] transition-colors duration-200 sm:size-7" style={{ background: c.hex }} />
<span className="flex flex-col items-start leading-tight">
{triggerLabel && <span className="text-[11.5px] text-muted-foreground">{triggerLabel}</span>}
<span className="font-medium tabular-nums text-foreground">{c.hex}</span>
</span>
<Icon name="chevron-down" size={14} className={cn('ms-1 text-subtle-foreground transition-[rotate] duration-300', open && 'rotate-180')} />
</button>
<PickerPanel open={open} mounted={mounted} anchor={triggerRef} panelRef={panelRef} label={triggerLabel ?? fieldLabel ?? t.choose} onEscape={() => { setOpen(false); triggerRef.current?.focus(); }}>
<Editor c={c} t={t} formats={formats} previous={before} showFormats={formats.length > 1} />
{(list.length > 0 || recent.length > 0) && (
<div className="mt-4 grid gap-2 border-t border-border pt-3.5">
{recent.length > 0 && <p className="text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">{t.recent}</p>}
<SwatchGroup swatches={[...recent.map((r) => ({ value: r })), ...list.filter((s) => !recent.includes(s.value.toUpperCase()))].slice(0, 12)} value={c.hex} onPick={c.setHex} label={recent.length ? t.recent : (fieldLabel ?? t.choose)} size="sm" className="gap-2" />
</div>
)}
</PickerPanel>
{hidden}
</>
);
}
function PickerPanel({ open, mounted, anchor, panelRef, label, onEscape, children }: { open: boolean; mounted: boolean; anchor: React.RefObject<HTMLButtonElement | null>; panelRef: React.RefObject<HTMLDivElement | null>; label: string; onEscape: () => void; children: React.ReactNode }) {
const { present, closing } = usePresence(open);
if (!mounted || !present) return null;
return (
<Floating anchor={anchor} side="bottom" align="start" offset={8} ref={panelRef}>
{(pos) => (
<div
role="dialog"
aria-label={label}
data-closing={closing || undefined}
inert={closing || undefined}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onEscape();
}
}}
className={cn('w-[19rem] max-w-[calc(100vw-16px)] p-3.5', panelSurface, floatingPop)}
style={{ '--origin': originFor(pos, 'bottom') } as React.CSSProperties}
>
{children}
</div>
)}
</Floating>
);
}
/**
* Choose a color from swatches, an editor with area and hue, or turn it into a scale.
* <ColorPicker label="Accent" swatches={['#1E6FD9', '#0E9F6E']} defaultValue="#1E6FD9" />
* <ColorPicker variant="popover" triggerLabel="Brand color" defaultValue="#E4572E" />
* <ColorPicker variant="inline" formats={['hex', 'rgb', 'hsl']} />
* <ColorPicker variant="palette" defaultValue="#1E6FD9" />
*/
export function ColorPicker({ label, hint, error, optional, labelAction, className, ...rest }: ColorPickerProps) {
const box = <PickerBox {...rest} fieldLabel={typeof label === 'string' ? label : undefined} className={label || hint || error ? undefined : className} />;
if (!label && !hint && !error) return box;
// Swatches, inline and palette are groups, their label names the group instead of pointing at one field
if (rest.variant !== 'popover') {
return (
<div className={cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)] gap-2.5', className)}>
{label && <p className="text-sm font-medium leading-5 text-foreground">{label}</p>}
{box}
{(error || hint) && <p className={cn('text-[13px]', error ? 'text-destructive' : 'text-muted-foreground')}>{error ?? hint}</p>}
</div>
);
}
return withField({ label, hint, error, optional, labelAction, id: rest.id, disabled: rest.disabled, className }, box);
}Frequent questions
How do I install Color picker?
With aiw add @ai-website/color-picker. The command puts components/ui/color-picker.tsx into your project, the code is yours from then on. It brings along icon, input, popover and copy-button. It needs no further npm packages.
Which variants does Color picker have?
4 variants, Swatches, Popover, Inline editor and Palette. You pick one with the variant prop, for example variant="inline".
Can I translate the texts of Color picker?
Yes. Color picker carries 21 texts of its own in English and German, such as “Choose a color”, “Custom color” and “Hex”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Color picker?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Color picker with aiw_components and install it with aiw_add @ai-website/color-picker. Name the variant in your prompt, for example variant="popover", and the assistant writes your content straight into the props.
Can I adjust Color picker without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ColorPicker code to copy.