Farbwähler
Eine Farbe aus benannten Feldern wählen, in einem Editor mit Fläche, Farbton, Pipette und HEX, RGB oder HSL, oder aus einer Farbe eine Skala mit Kontrastwerten machen.
Eine von 7 Components in Picker und Auswahlfelder
Variants
Details
Was Farbwähler mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Picker und Auswahlfelder
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/color-picker.tsx
- Bringt mit
- utilskiticoninputpopovercopy-button
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 21 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ColorPickerProps aus components/ui/color-picker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.
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
Eingebaute Texte
Diese Texte schreibt Farbwähler selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 21 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| choose | Englisch Choose a color | Deutsch Farbe wählen |
| custom | Englisch Custom color | Deutsch Eigene Farbe |
| hex | Englisch Hex | Deutsch Hex |
| area | Englisch Saturation and brightness | Deutsch Sättigung und Helligkeit |
| saturation | Englisch Saturation | Deutsch Sättigung |
| brightness | Englisch Brightness | Deutsch Helligkeit |
| hue | Englisch Hue | Deutsch Farbton |
| pick | Englisch Pick from screen | Deutsch Vom Bildschirm aufnehmen |
| recent | Englisch Recent | Deutsch Zuletzt |
| before | Englisch Before | Deutsch Vorher |
| after | Englisch New | Deutsch Neu |
| format | Englisch Format | Deutsch Format |
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| invalid | Englisch Enter a color like #1E6FD9 | Deutsch Gib eine Farbe wie #1E6FD9 ein |
| aa | Englisch AA | Deutsch AA |
| aaa | Englisch AAA | Deutsch AAA |
| large | Englisch Large text | Deutsch Großer Text |
| fail | Englisch Too low | Deutsch Zu gering |
| white | Englisch White text | Deutsch Weiße Schrift |
| black | Englisch Black text | Deutsch Schwarze Schrift |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Farbwähler reagiert selbst auf die Tasten Escape, Enter, Pos1, Ende und Leertaste.
- Setzt aria-checked, aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen dialog, radio, radiogroup und slider.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 653 Zeilen
'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);
}Häufige Fragen
Wie installiere ich Farbwähler?
Mit aiw add @ai-website/color-picker. Der Befehl legt die Datei components/ui/color-picker.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input, popover und copy-button. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Farbwähler?
4 Varianten, Farbfelder, Popover, Editor im Fluss und Palette. Du wählst sie mit der Prop variant, zum Beispiel variant="inline".
Kann ich die Texte von Farbwähler übersetzen?
Ja. Farbwähler bringt 21 eigene Texte auf Deutsch und Englisch mit, etwa „Farbe wählen“, „Eigene Farbe“ und „Hex“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Farbwähler einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Farbwähler mit aiw_components und installieren sie mit aiw_add @ai-website/color-picker. Nenn im Prompt die Variante, etwa variant="popover", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Farbwähler ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ColorPicker-Code zum Kopieren.