Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Farbfelder

Benannte Farbfelder als Radiogruppe, Pfeile bewegen und wählen. Der Regenbogenring öffnet den Farbdialog des Systems, das Hexfeld nimmt jede Farbe, die gezeichnete Seite übernimmt sie sofort.

Im Studio öffnen

Popover

Ein Auslöser mit Farbfeld und Hex, ein Panel mit Fläche für Sättigung und Helligkeit, Farbtonregler, vorher und nachher, Pipette, wo der Browser sie hat, und zuletzt genutzte Farben.

Im Studio öffnen

Editor im Fluss

Der ganze Editor auf der Seite, mit Umschalter zwischen HEX, RGB und HSL. Pfeile bewegen die Fläche, Umschalt in großen Schritten, ein Kalendereintrag zeigt die Farbe mit lesbarer Schrift.

Im Studio öffnen

Palette

Aus einer Farbe werden zehn Stufen von 50 bis 900, jede mit Kontrastwert zur lesbaren Schrift und WCAG-Stufe. Ein Tipp kopiert den Hexwert, die Stufen wachsen beim Darüberfahren ein wenig.

Im Studio öffnen

Details

Was Farbwähler mitbringt, aus Registry und Quellcode gelesen.

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

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.

  • variantoptional
    ColorPickerVariant

    swatchespopoverinlinepalette

  • valueoptional
    string

    The color as #RRGGBB

  • defaultValueoptional
    string
  • onValueChangeoptional
    (hex: string) => void
  • swatchesoptional
    (string | ColorSwatch)[]

    Preset colors, as hex strings or { value, label }

  • allowCustomoptional
    boolean

    swatches: offer a custom color through the system picker

  • formatsoptional
    ('hex' | 'rgb' | 'hsl')[]

    inline: show the formats HEX, RGB and HSL

  • triggerLabeloptional
    string

    popover: the trigger text, e.g. "Brand color"

  • stepsoptional
    number[]

    palette: steps of the scale

  • defaultOpenoptional
    boolean

    Open on first render, for demos

  • idoptional
    string
  • nameoptional
    string
  • disabledoptional
    boolean

Eingebaute Texte

Diese Texte schreibt Farbwähler selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 21 Texte anzeigen
chooseEnglisch Choose a colorDeutsch Farbe wählen
customEnglisch Custom colorDeutsch Eigene Farbe
hexEnglisch HexDeutsch Hex
areaEnglisch Saturation and brightnessDeutsch Sättigung und Helligkeit
saturationEnglisch SaturationDeutsch Sättigung
brightnessEnglisch BrightnessDeutsch Helligkeit
hueEnglisch HueDeutsch Farbton
pickEnglisch Pick from screenDeutsch Vom Bildschirm aufnehmen
recentEnglisch RecentDeutsch Zuletzt
beforeEnglisch BeforeDeutsch Vorher
afterEnglisch NewDeutsch Neu
formatEnglisch FormatDeutsch Format
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
invalidEnglisch Enter a color like #1E6FD9Deutsch Gib eine Farbe wie #1E6FD9 ein
aaEnglisch AADeutsch AA
aaaEnglisch AAADeutsch AAA
largeEnglisch Large textDeutsch Großer Text
failEnglisch Too lowDeutsch Zu gering
whiteEnglisch White textDeutsch Weiße Schrift
blackEnglisch Black textDeutsch 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
components/ui/color-picker.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert