Skip to content
UI Kit
Accent color

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

4 variants

Variants

Swatches

Named swatches as a radio group, arrows move and choose. The rainbow ring opens the system color dialog, the hex field takes any color, the drawn site takes it on at once.

Open in Studio

Popover

A trigger with swatch and hex, a panel with a field for saturation and brightness, a hue slider, before and after, the eyedropper where the browser has one, and recent colors.

Open in Studio

Inline editor

The full editor on the page, with a switch between HEX, RGB and HSL. Arrows move the field, Shift takes big steps, a calendar entry shows the color with readable text.

Open in Studio

Palette

One color becomes ten steps from 50 to 900, each with its contrast ratio to readable text and the WCAG grade. A tap copies the hex, the steps grow a little on hover.

Open in Studio

Details

What Color picker brings along, read from the registry and the source.

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

Props

The ColorPickerProps interface from components/ui/color-picker.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • 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

Built-in texts

Color picker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 21 texts
chooseEnglish Choose a colorGerman Farbe wählen
customEnglish Custom colorGerman Eigene Farbe
hexEnglish HexGerman Hex
areaEnglish Saturation and brightnessGerman Sättigung und Helligkeit
saturationEnglish SaturationGerman Sättigung
brightnessEnglish BrightnessGerman Helligkeit
hueEnglish HueGerman Farbton
pickEnglish Pick from screenGerman Vom Bildschirm aufnehmen
recentEnglish RecentGerman Zuletzt
beforeEnglish BeforeGerman Vorher
afterEnglish NewGerman Neu
formatEnglish FormatGerman Format
copyEnglish CopyGerman Kopieren
copiedEnglish CopiedGerman Kopiert
invalidEnglish Enter a color like #1E6FD9German Gib eine Farbe wie #1E6FD9 ein
aaEnglish AAGerman AA
aaaEnglish AAAGerman AAA
largeEnglish Large textGerman Großer Text
failEnglish Too lowGerman Zu gering
whiteEnglish White textGerman Weiße Schrift
blackEnglish Black textGerman 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
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);
}

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.

Often searched

Jump to

Copied to the clipboard