mwui
GitHub

Color Picker

An accessible, composable color picker with a saturation area, hue and alpha sliders, eyedropper, swatches, and hex/rgb/hsl/oklch output.

#
A

#3b82f6

Installation

Usage

A headless-ish set of parts rather than one fixed widget: ColorPicker owns the color state and every other part reads from it, so you arrange the surface you actually need. Nothing renders unless you ask for it — drop ColorPickerAlpha if your colors are always opaque, drop ColorPickerFormatSelect if you only ever emit hex.

import {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHue,
  ColorPickerInput,
  ColorPickerPreview,
  ColorPickerSwatch,
  ColorPickerSwatches,
} from "@/components/ui/color-picker";

export function BrandColor() {
  const [color, setColor] = useState("#3b82f6");

  return (
    <ColorPicker value={color} onValueChange={setColor}>
      <ColorPickerArea />
      <div className="flex items-center gap-3">
        <ColorPickerEyeDropper />
        <div className="flex min-w-0 flex-1 flex-col gap-2">
          <ColorPickerHue />
          <ColorPickerAlpha />
        </div>
        <ColorPickerPreview />
      </div>
      <ColorPickerFormatSelect />
      <ColorPickerInput alpha />
      <ColorPickerSwatches>
        <ColorPickerSwatch value="#ef4444" />
        <ColorPickerSwatch value="#22c55e" />
        <ColorPickerSwatch value="#3b82f6" />
      </ColorPickerSwatches>
    </ColorPicker>
  );
}

State

ColorPicker is controlled with value / onValueChange and uncontrolled with defaultValue. onValueChange hands you a CSS color string serialized in the active format, so the value you get back is the value you can paste straight into a stylesheet.

<ColorPicker defaultValue="oklch(0.62 0.19 259)" defaultFormat="oklch" />

Internally the color is stored as HSVA, not as the string. That is what lets you drag into pure black and drag back out onto the hue you started from — a picker that round-trips through hex loses the hue the moment brightness hits zero.

format / defaultFormat / onFormatChange control the output format the same way. Switching format fires onValueChange too, since the value string changes with it.

Pass name to submit the value with a plain HTML form:

<ColorPicker name="accent" defaultValue="#3b82f6" />

Parts

PartNotes
ColorPickerState owner and context provider. Accepts disabled and name.
ColorPickerAreaSaturation and brightness plane.
ColorPickerHueHue slider, 0–360.
ColorPickerAlphaAlpha slider over a checkerboard.
ColorPickerPreviewCurrent color chip. Size it with className.
ColorPickerEyeDropperScreen sampler. Renders nothing where the EyeDropper API is unavailable.
ColorPickerFormatSelectSegmented format control. Narrow it with formats={["hex", "rgb"]}.
ColorPickerInputFormat-aware fields — a hex box, or one field per channel. alpha adds one.
ColorPickerSwatchesPreset container.
ColorPickerSwatchOne preset. value takes any parseable color string.

Popover trigger

The item ships the panel, not the trigger, so it composes with whatever overlay you already use:

<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>
    <span className="size-4 rounded" style={{ backgroundColor: color }} />
    {color}
  </PopoverTrigger>
  <PopoverContent className="w-64">
    <ColorPicker value={color} onValueChange={setColor}>
      <ColorPickerArea />
      <ColorPickerHue />
      <ColorPickerInput />
    </ColorPicker>
  </PopoverContent>
</Popover>

Custom parts

useColorPicker exposes the same context the built-in parts use, so you can add your own without forking the component:

import { useColorPicker } from "@/components/ui/color-picker";

function CopyColor() {
  const { value, description } = useColorPicker();

  return (
    <Button variant="ghost" onClick={() => navigator.clipboard.writeText(value)}>
      Copy {description}
    </Button>
  );
}

It returns hsva, setColor, rgb, hex, cssColor, value, format, setFormat, description, and disabled.

Color utilities

parseColor, formatColor, and describeColor are exported for use outside the picker. parseColor reads #rgb, #rgba, #rrggbb, #rrggbbaa, rgb(), hsl(), and oklch() in both the legacy comma syntax and the modern space/slash syntax, and returns null rather than throwing on input it cannot read.

formatColor(parseColor("#3b82f6")!, "oklch"); // "oklch(0.6231 0.188 259.81)"
describeColor(parseColor("#3b82f6")!); // "vibrant blue"

Accessibility

The saturation plane is backed by two visually hidden range inputs — one per axis — following the pattern React Aria uses for its ColorArea. Screen readers get a real value to announce, keyboard users get a real control to focus, and mobile assistive tech gets its native slider gestures. Arrow keys drive both axes from either input, so focus order never changes what the keys do.

Every control announces a plain-language color description next to its numeric value — "62%, dark vibrant blue" rather than "182, 96, 38". Descriptions are derived in OKLCH, whose lightness is perceptually even across hues, so "dark" means the same thing on yellow as it does on blue.

KeyAction
Saturation, or the slider value, by one step
Brightness in the area
Shift + arrowTen steps
Home EndMinimum / maximum
Page Up Page DownTen steps