Skip to content
38

Color Picker

Color picker on react-colorful with HEX / RGB / HSL inputs (colord), an eyedropper where supported, and recent swatches.

Example

Installation

npx shadcn@latest add https://hirael.com/r/color-picker.json

Usage

import {
  ColorPicker,
  ColorPickerTrigger,
  ColorPickerContent,
  ColorPickerArea,
  ColorPickerFormatTabs,
  ColorPickerFormatInputs,
  ColorPickerEyedropper,
  ColorPickerSwatches,
} from "@/components/color-picker"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<ColorPicker />

PropTypeDefault
valuestringnone
defaultValuestring'#0ea5e9'
onValueChange((hex: string) => void)none
formatColorFormatnone
defaultFormatColorFormat'hex'
onFormatChange((format: ColorFormat) => void)none
swatchesstring[]none
recentLimitnumber8
disabledbooleannone
openbooleannone
defaultOpenbooleanfalse
onOpenChange((open: boolean) => void)none
childrenReact.ReactNodenone

<ColorPickerTrigger />

+ native element props
PropTypeDefault
placeholderstring'Pick a color'
childrenReact.ReactNodenone

<ColorPickerContent />

+ native element props

No props of its own; forwards everything to the underlying element.

<ColorPickerArea />

+ native element props

No props of its own; forwards everything to the underlying element.

<ColorPickerFormatTabs />

+ native element props

No props of its own; forwards everything to the underlying element.

<ColorPickerFormatInputs />

+ native element props
PropTypeDefault
hexLabel

Accessible name of the hex field.

string'Hex color'
channelLabel

Accessible name of an RGB or HSL channel field.

((channel: string) => string)defaultChannelLabel

<ColorPickerEyedropper />

+ native element props

No props of its own; forwards everything to the underlying element.

<ColorPickerSwatches />

+ native element props
PropTypeDefault
swatchLabel

Accessible name of a swatch button.

((color: string) => string)defaultSwatchLabel

Component source

'use client';

import * as React from 'react';
import { colord } from 'colord';
import { Pipette } from 'lucide-react';
import { HexColorPicker } from 'react-colorful';

import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { Tabs, TabsList, TabsTrigger } from '@/registry/hirael/bases/radix/ui/tabs';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';

export type ColorFormat = 'hex' | 'rgb' | 'hsl';

interface ColorPickerContextValue {
  hex: string;
  setHex: (hex: string) => void;
  format: ColorFormat;
  setFormat: (f: ColorFormat) => void;
  swatches: string[];
  pushSwatch: (hex: string) => void;
  disabled?: boolean;
}

const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(null);

const useColorPicker = () => {
  const ctx = React.useContext(ColorPickerContext);
  if (!ctx) {
    throw new Error('ColorPicker compound components must be used inside <ColorPicker>');
  }

  return ctx;
};

const DEFAULT_SWATCHES = ['#0ea5e9', '#22c55e', '#a855f7', '#f43f5e', '#f97316', '#facc15', '#14b8a6', '#64748b'];

export interface ColorPickerProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (hex: string) => void;
  format?: ColorFormat;
  defaultFormat?: ColorFormat;
  onFormatChange?: (format: ColorFormat) => void;
  swatches?: string[];
  recentLimit?: number;
  disabled?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children?: React.ReactNode;
}

const ColorPicker = ({
  value: valueProp,
  defaultValue = '#0ea5e9',
  onValueChange,
  format: formatProp,
  defaultFormat = 'hex',
  onFormatChange,
  swatches: swatchesProp,
  recentLimit = 8,
  disabled,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  children,
}: ColorPickerProps) => {
  const [value, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue,
    onChange: onValueChange,
  });

  const [format, setFormat] = useControllableState<ColorFormat>({
    prop: formatProp,
    defaultProp: defaultFormat,
    onChange: onFormatChange,
  });

  const [recent, setRecent] = React.useState<string[]>([]);
  const pushSwatch = React.useCallback(
    (hex: string) =>
      setRecent((prev) => [hex, ...prev.filter((c) => c.toLowerCase() !== hex.toLowerCase())].slice(0, recentLimit)),
    [recentLimit],
  );

  const [open, setControlledOpen] = useControllableState({
    prop: openProp,
    defaultProp: defaultOpen,
    onChange: onOpenChange,
  });
  const setOpen = (next: boolean) => {
    setControlledOpen(next);
    // Remember the colour the popover closes on, not every step of a drag.
    if (!next) pushSwatch(value.toLowerCase());
  };

  const swatches = React.useMemo(() => {
    const merged = swatchesProp ? [...swatchesProp, ...recent] : [...recent, ...DEFAULT_SWATCHES];

    return merged.filter((c, i) => merged.findIndex((o) => o.toLowerCase() === c.toLowerCase()) === i).slice(0, 16);
  }, [swatchesProp, recent]);

  const ctx = React.useMemo<ColorPickerContextValue>(
    () => ({ hex: value, setHex: setValue, format, setFormat, swatches, pushSwatch, disabled }),
    [value, setValue, format, setFormat, swatches, pushSwatch, disabled],
  );

  return (
    <ColorPickerContext.Provider value={ctx}>
      <Popover open={open} onOpenChange={setOpen}>
        {children}
      </Popover>
    </ColorPickerContext.Provider>
  );
};

interface ColorPickerTriggerProps extends Omit<React.ComponentProps<'button'>, 'children'> {
  placeholder?: string;
  children?: React.ReactNode;
}

const ColorPickerTrigger = ({
  placeholder = 'Pick a color',
  className,
  children,
  ...props
}: ColorPickerTriggerProps) => {
  const ctx = useColorPicker();

  return (
    <PopoverTrigger asChild>
      <Button
        type="button"
        variant="outline"
        disabled={ctx.disabled}
        data-slot="color-picker-trigger"
        className={cn('w-full justify-between font-normal uppercase tabular-nums', className)}
        {...props}
      >
        {children ?? (
          <span className="flex items-center gap-2">
            <span
              aria-hidden
              className="inline-block size-5 shrink-0 rounded-sm border border-border"
              style={{ backgroundColor: ctx.hex }}
            />
            <span>{ctx.hex || placeholder}</span>
          </span>
        )}
      </Button>
    </PopoverTrigger>
  );
};

/** Saturation, brightness and hue in one control. */
const ColorPickerArea = ({ className, ...props }: React.ComponentProps<'div'>) => {
  const ctx = useColorPicker();

  return (
    <div
      data-slot="color-picker-area"
      className={cn('[&_.react-colorful]:w-full', ctx.disabled && 'pointer-events-none opacity-50', className)}
      {...props}
    >
      <HexColorPicker color={ctx.hex} onChange={ctx.setHex} />
    </div>
  );
};

const ColorPickerFormatTabs = ({
  className,
  ...props
}: Omit<React.ComponentProps<typeof Tabs>, 'value' | 'onValueChange'>) => {
  const ctx = useColorPicker();

  return (
    <Tabs
      {...props}
      data-slot="color-picker-format-tabs"
      value={ctx.format}
      onValueChange={(v) => ctx.setFormat(v as ColorFormat)}
      className={className}
    >
      <TabsList className="w-full">
        {(['hex', 'rgb', 'hsl'] as const).map((f) => (
          <TabsTrigger key={f} value={f} className="text-xs uppercase">
            {f}
          </TabsTrigger>
        ))}
      </TabsList>
    </Tabs>
  );
};

interface ChannelInputProps {
  value: number;
  max: number;
  onCommit: (n: number) => void;
  'aria-label': string;
}

// Keeps the raw text while editing, so clearing a field doesn't snap it to 0; out-of-range input clamps on blur or Enter.
const ChannelInput = ({ value, max, onCommit, 'aria-label': ariaLabel }: ChannelInputProps) => {
  const [draft, setDraft] = React.useState<string | null>(null);
  const parse = (raw: string) => (/^\d+$/.test(raw.trim()) ? Number(raw) : null);

  const commit = () => {
    const n = draft === null ? null : parse(draft);
    if (n !== null) onCommit(Math.min(max, n));
    setDraft(null);
  };

  return (
    <Input
      value={draft ?? String(Math.round(value))}
      onChange={(e) => {
        setDraft(e.target.value);
        const n = parse(e.target.value);
        if (n !== null && n <= max) onCommit(n);
      }}
      onBlur={commit}
      onKeyDown={(e) => {
        if (e.key === 'Enter') commit();
      }}
      inputMode="numeric"
      aria-label={ariaLabel}
      className="h-8 px-2 text-center text-xs tabular-nums"
    />
  );
};

interface ColorPickerFormatInputsProps extends React.ComponentProps<'div'> {
  /** Accessible name of the hex field. */
  hexLabel?: string;
  /** Accessible name of an RGB or HSL channel field. */
  channelLabel?: (channel: string) => string;
}

const defaultChannelLabel = (channel: string) => `${channel.toUpperCase()} channel`;

const ColorPickerFormatInputs = ({
  hexLabel = 'Hex color',
  channelLabel = defaultChannelLabel,
  className,
  ...props
}: ColorPickerFormatInputsProps) => {
  const ctx = useColorPicker();
  const color = colord(ctx.hex);
  const [hexDraft, setHexDraft] = React.useState<string | null>(null);

  if (ctx.format === 'hex') {
    const commitHex = () => {
      const next = hexDraft && colord(hexDraft.startsWith('#') ? hexDraft : `#${hexDraft}`);
      if (next && next.isValid()) {
        ctx.setHex(next.toHex());
        ctx.pushSwatch(next.toHex());
      }
      setHexDraft(null);
    };

    return (
      <div {...props} data-slot="color-picker-format-inputs" className={className}>
        <Input
          value={hexDraft ?? ctx.hex.toUpperCase()}
          onChange={(e) => {
            setHexDraft(e.target.value.toUpperCase());
            const next = colord(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`);
            if (/^#?[0-9a-f]{6}$/i.test(e.target.value.trim()) && next.isValid()) ctx.setHex(next.toHex());
          }}
          onBlur={commitHex}
          onKeyDown={(e) => {
            if (e.key === 'Enter') commitHex();
          }}
          aria-label={hexLabel}
          className="h-8 text-xs uppercase tabular-nums"
        />
      </div>
    );
  }

  const channels =
    ctx.format === 'rgb'
      ? (['r', 'g', 'b'] as const).map((k) => ({
          k,
          value: color.toRgb()[k],
          max: 255,
          commit: (n: number) => ctx.setHex(colord({ ...color.toRgb(), [k]: n }).toHex()),
        }))
      : (['h', 's', 'l'] as const).map((k) => ({
          k,
          value: color.toHsl()[k],
          max: k === 'h' ? 360 : 100,
          commit: (n: number) => ctx.setHex(colord({ ...color.toHsl(), [k]: n }).toHex()),
        }));

  return (
    <div {...props} data-slot="color-picker-format-inputs" className={cn('grid grid-cols-3 gap-1.5', className)}>
      {channels.map((c) => (
        <ChannelInput key={c.k} value={c.value} max={c.max} onCommit={c.commit} aria-label={channelLabel(c.k)} />
      ))}
    </div>
  );
};

interface EyeDropperResult {
  sRGBHex: string;
}
type EyeDropperCtor = new () => { open: () => Promise<EyeDropperResult> };

const ColorPickerEyedropper = ({
  'aria-label': ariaLabel = 'Pick color from screen',
  className,
  ...props
}: Omit<React.ComponentProps<'button'>, 'onClick' | 'children'>) => {
  const ctx = useColorPicker();
  const supported = React.useSyncExternalStore(
    () => () => {},
    () => 'EyeDropper' in window,
    () => false,
  );

  if (!supported) return null;

  return (
    <Button
      {...props}
      type="button"
      variant="outline"
      size="icon-sm"
      data-slot="color-picker-eyedropper"
      aria-label={ariaLabel}
      onClick={async () => {
        try {
          const Ctor = (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper;
          const dropper = new Ctor();
          const res = await dropper.open();
          const next = res.sRGBHex.toLowerCase();
          ctx.setHex(next);
          ctx.pushSwatch(next);
        } catch {}
      }}
      className={cn('shrink-0', className)}
    >
      <Pipette className="size-3.5" />
    </Button>
  );
};

interface ColorPickerSwatchesProps extends React.ComponentProps<'div'> {
  /** Accessible name of a swatch button. */
  swatchLabel?: (color: string) => string;
}

const defaultSwatchLabel = (color: string) => `Use ${color}`;

const ColorPickerSwatches = ({ swatchLabel = defaultSwatchLabel, className, ...props }: ColorPickerSwatchesProps) => {
  const ctx = useColorPicker();
  if (ctx.swatches.length === 0) return null;

  return (
    <div {...props} data-slot="color-picker-swatches" className={cn('flex flex-wrap gap-1', className)}>
      {ctx.swatches.map((s) => {
        const active = s.toLowerCase() === ctx.hex.toLowerCase();

        return (
          <button
            key={s}
            type="button"
            data-slot="color-picker-swatch"
            aria-label={swatchLabel(s)}
            aria-pressed={active}
            onClick={() => {
              ctx.setHex(s.toLowerCase());
              ctx.pushSwatch(s.toLowerCase());
            }}
            className={cn(
              'size-5 rounded-sm border transition-transform hover:scale-110',
              active ? 'border-foreground ring-1 ring-foreground' : 'border-border',
            )}
            style={{ backgroundColor: s }}
          />
        );
      })}
    </div>
  );
};

/** Renders the default layout; pass children to compose your own from the parts. */
const ColorPickerContent = ({ className, children, ...props }: React.ComponentProps<typeof PopoverContent>) => {
  const ctx = useColorPicker();

  return (
    <PopoverContent align="start" data-slot="color-picker-content" className={cn('w-64 p-3', className)} {...props}>
      {children ?? (
        <div className="flex flex-col gap-3">
          <ColorPickerArea />
          <div className="flex items-center gap-2">
            <span
              aria-hidden
              data-slot="color-picker-preview"
              className="inline-block h-8 flex-1 rounded-sm border border-border"
              style={{ backgroundColor: ctx.hex }}
            />
            <ColorPickerEyedropper />
          </div>
          <ColorPickerFormatTabs />
          <ColorPickerFormatInputs />
          <ColorPickerSwatches />
        </div>
      )}
    </PopoverContent>
  );
};

export {
  ColorPicker,
  ColorPickerTrigger,
  ColorPickerContent,
  ColorPickerArea,
  ColorPickerFormatTabs,
  ColorPickerFormatInputs,
  ColorPickerEyedropper,
  ColorPickerSwatches,
};

Dependencies

shadcn registry

buttoninputpopovertabsuse-controllable-state

npm

colordlucide-reactreact-colorful