For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Color Picker

Choose a colour by eye or by hex, with an optional opacity rail and preset swatches.

Installation

$ pnpm dlx shadcn@latest add @cobalt/color-picker

Usage

import { useState } from "react";
 
import { ColorPicker, ColorWell } from "@/registry/cobalt/ui/color-picker";
 
const [color, setColor] = useState("#2563eb");
const [tint, setTint] = useState("#0ea5e9");
 
<ColorPicker value={color} onChange={setColor} />
<ColorWell
  aria-label="Brand color"
  value={color}
  onChange={setColor}
  swatches={["#1d4ed8", "#0ea5e9", "#10b981"]}
/>

value and onChange speak hex strings, so a picker can be stored next to any other colour field. The editor keeps the colour in HSB while you drag, so the hue survives a trip through grey.

Props

ColorPicker

PropTypeDefaultDescription
valuestring—Controlled colour: hex, rgb() or hsl().
defaultValuestring#000000Initial colour, when uncontrolled.
onChange(value: string) => void—Called with the colour as a hex string.
showAlphabooleanfalseAdds an opacity rail over a checkerboard.
swatchesstring[]—Preset colours in a row under the editor.
classNamestring—Classes on the editor. It defaults to 228px.

ColorWell

Takes every ColorPicker prop, plus:

PropTypeDefaultDescription
aria-labelstringColorNames the swatch button and its popover.
sidetop | bottom | left | rightbottomWhich side the editor opens on.
alignstart | center | endstartHow the editor lines up with the swatch.
wellClassNamestring—Classes on the swatch button.
popoverClassNamestring—Classes on the popover surface.

The editor opens in a portal, so pass a theme scope to popoverClassName when the popover needs the same variables as its surroundings.

Source

"use client";

import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cn } from "cn";
import * as React from "react";

import type { ColorPickerColor } from "@/components/lib/color-picker-color";
import {
  colorToCss,
  colorToHex,
  colorToHsl,
  parseColor,
} from "@/components/lib/color-picker-color";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

/**
 * Color picker — choose a colour by eye or by hex: a brand colour, a label, a
 * chart series.
 *
 *   <ColorPicker value={color} onChange={setColor} />
 *   <ColorWell aria-label="Brand color" value={color} onChange={setColor} />
 *
 * A saturation and brightness field and a hue rail, both with the slider's
 * white embossed thumb, and a hex row on the input's tertiary surface.
 * `showAlpha` adds an opacity rail over a checkerboard, and `swatches` adds a
 * row of presets. `ColorWell` is a round swatch that opens the same editor in a
 * popover.
 *
 * The colour is kept in HSB while you drag, so its hue survives a trip through
 * grey, each control is reachable by keyboard (arrows nudge, Page keys take
 * bigger steps), and the hex field takes any CSS colour and commits on Enter or
 * blur. Values are hex strings.
 */

const BLACK: ColorPickerColor = {
  hue: 0,
  saturation: 0,
  brightness: 0,
  alpha: 1,
};

const DEFAULT_COLOR = "#000000";

/** The slider's white embossed thumb, filled with the colour under it. */
const THUMB =
  "size-4 rounded-full border border-white shadow-[0_2px_4px_rgb(0_0_0/0.18),inset_0_0_0_1px_rgb(0_0_0/0.06)] transition-[scale] duration-150 ease-out group-data-[dragging]/area:scale-110 data-dragging:scale-110 motion-reduce:transition-none";

/** The thumb's focus ring. The range input inside it takes the focus. */
const THUMB_RING =
  "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2";

/** The inset hairline that keeps light colours from melting into the page. */
const HAIRLINE =
  "pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.06)]";

const CHECKERBOARD =
  "bg-[repeating-conic-gradient(var(--color-picker-checker-alt,var(--muted))_0%_25%,var(--surface-card,var(--background))_0%_50%)] bg-[length:8px_8px]";

const FOCUS_RING =
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";

interface ColorPickerContextValue {
  color: ColorPickerColor;
  setColor: (color: ColorPickerColor) => void;
}

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

function useColorPicker() {
  const context = React.useContext(ColorPickerContext);
  if (!context) {
    throw new Error("ColorPicker parts must render inside <ColorPicker>.");
  }

  return context;
}

const clamp = (value: number, min: number, max: number) =>
  Math.min(max, Math.max(min, value));

interface AreaThumbProps {
  axis: "x" | "y";
  color: ColorPickerColor;
  inputRef?: React.Ref<HTMLInputElement>;
  label: string;
  left: number;
  top: number;
  value: number;
  tabIndex: number;
  focusRing: boolean;
  onFocus: () => void;
  onBlur: () => void;
  onValueChange: (value: number) => void;
}

/** One axis of the saturation and brightness field: a thumb with a hidden
 * range input, so arrows nudge and Page keys take bigger steps. */
function AreaThumb({
  axis,
  color,
  inputRef,
  label,
  left,
  top,
  value,
  tabIndex,
  focusRing,
  onFocus,
  onBlur,
  onValueChange,
}: AreaThumbProps) {
  return (
    <div
      role="presentation"
      className={cn(
        THUMB,
        focusRing && THUMB_RING,
        "absolute -translate-x-1/2 -translate-y-1/2"
      )}
      style={{
        left: `${left}%`,
        top: `${top}%`,
        backgroundColor: colorToCss(color),
      }}
    >
      <input
        ref={inputRef}
        type="range"
        min={0}
        max={100}
        step={1}
        value={Math.round(value)}
        aria-label={label}
        aria-orientation={axis === "y" ? "vertical" : "horizontal"}
        tabIndex={tabIndex}
        onChange={(event) => onValueChange(Number(event.target.value))}
        onFocus={onFocus}
        onBlur={onBlur}
        className="pointer-events-none absolute inset-0 size-full appearance-none opacity-0"
      />
    </div>
  );
}

/** The saturation and brightness field. */
function ColorArea() {
  const { color, setColor } = useColorPicker();
  const areaRef = React.useRef<HTMLDivElement>(null);
  const saturationRef = React.useRef<HTMLInputElement>(null);
  const brightnessRef = React.useRef<HTMLInputElement>(null);
  const draggingRef = React.useRef(false);
  const [isDragging, setIsDragging] = React.useState(false);
  const [focusedAxis, setFocusedAxis] = React.useState<"x" | "y" | null>(null);
  // A scripted focus after a pointer press still matches :focus-visible in
  // Chrome, so track the modality ourselves and keep the ring for the keyboard.
  const [pointerFocus, setPointerFocus] = React.useState(false);

  const { hue } = colorToHsl(color);
  const background = [
    "linear-gradient(to top, black, transparent)",
    "linear-gradient(to right, hsl(0 0% 100%), transparent)",
    `hsl(${hue} 100% 50%)`,
  ].join(", ");

  function setFromPoint(clientX: number, clientY: number) {
    const rect = areaRef.current?.getBoundingClientRect();
    if (!rect) {
      return;
    }

    const x = clamp((clientX - rect.left) / rect.width, 0, 1);
    const y = clamp((clientY - rect.top) / rect.height, 0, 1);
    setColor({
      ...color,
      saturation: Math.round(x * 100),
      brightness: Math.round((1 - y) * 100),
    });
  }

  function endDrag() {
    draggingRef.current = false;
    setIsDragging(false);
  }

  // The field is one two-dimensional control: arrows nudge either axis and
  // Page keys take bigger steps, so each axis has its own hidden range input.
  function nudge(event: React.KeyboardEvent<HTMLDivElement>) {
    const step = event.shiftKey ? 10 : 1;
    let next = color;
    let axis: "x" | "y" = "x";

    switch (event.key) {
      case "ArrowLeft": {
        next = { ...color, saturation: clamp(color.saturation - step, 0, 100) };
        break;
      }
      case "ArrowRight": {
        next = { ...color, saturation: clamp(color.saturation + step, 0, 100) };
        break;
      }
      case "ArrowUp": {
        next = { ...color, brightness: clamp(color.brightness + step, 0, 100) };
        axis = "y";
        break;
      }
      case "ArrowDown": {
        next = { ...color, brightness: clamp(color.brightness - step, 0, 100) };
        axis = "y";
        break;
      }
      case "PageUp": {
        next = { ...color, brightness: clamp(color.brightness + 10, 0, 100) };
        axis = "y";
        break;
      }
      case "PageDown": {
        next = { ...color, brightness: clamp(color.brightness - 10, 0, 100) };
        axis = "y";
        break;
      }
      case "Home": {
        next = { ...color, saturation: 0 };
        break;
      }
      case "End": {
        next = { ...color, saturation: 100 };
        break;
      }
      default: {
        return;
      }
    }

    event.preventDefault();
    setPointerFocus(false);
    setColor(next);
    (axis === "x" ? saturationRef : brightnessRef).current?.focus();
  }

  const top = 100 - color.brightness;

  return (
    <div
      ref={areaRef}
      role="group"
      aria-label="Color"
      data-dragging={isDragging || undefined}
      style={{ background }}
      className="group/area relative aspect-[3/2] w-full shrink-0 cursor-crosshair touch-none rounded-[16px]"
      onPointerDown={(event) => {
        if (
          event.pointerType === "mouse" &&
          (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey)
        ) {
          return;
        }

        event.currentTarget.setPointerCapture(event.pointerId);
        event.preventDefault();
        draggingRef.current = true;
        setIsDragging(true);
        setPointerFocus(true);
        saturationRef.current?.focus();
        setFromPoint(event.clientX, event.clientY);
      }}
      onKeyDown={nudge}
      onPointerMove={(event) => {
        if (!draggingRef.current) {
          return;
        }

        setFromPoint(event.clientX, event.clientY);
      }}
      onPointerUp={(event) => {
        if (event.currentTarget.hasPointerCapture(event.pointerId)) {
          event.currentTarget.releasePointerCapture(event.pointerId);
        }
        endDrag();
      }}
      onPointerCancel={endDrag}
    >
      <span aria-hidden className={cn(HAIRLINE, "rounded-[16px]")} />
      <AreaThumb
        axis="x"
        color={color}
        inputRef={saturationRef}
        label="Saturation"
        left={color.saturation}
        top={top}
        value={color.saturation}
        tabIndex={focusedAxis === "y" ? -1 : 0}
        focusRing={!pointerFocus}
        onFocus={() => setFocusedAxis("x")}
        onBlur={() => {
          setFocusedAxis(null);
          setPointerFocus(false);
        }}
        onValueChange={(saturation) => setColor({ ...color, saturation })}
      />
      <AreaThumb
        axis="y"
        color={color}
        inputRef={brightnessRef}
        label="Brightness"
        left={color.saturation}
        top={top}
        value={color.brightness}
        tabIndex={focusedAxis === "y" ? 0 : -1}
        focusRing={!pointerFocus}
        onFocus={() => setFocusedAxis("y")}
        onBlur={() => {
          setFocusedAxis(null);
          setPointerFocus(false);
        }}
        onValueChange={(brightness) => setColor({ ...color, brightness })}
      />
    </div>
  );
}

/** The hue rail, or the opacity rail over a checkerboard. */
function ColorSlider({ channel }: { channel: "hue" | "alpha" }) {
  const { color, setColor } = useColorPicker();

  const label = channel === "hue" ? "Hue" : "Alpha";
  const value =
    channel === "hue" ? Math.round(color.hue) : Math.round(color.alpha * 100);
  const max = channel === "hue" ? 360 : 100;
  const background =
    channel === "hue"
      ? `linear-gradient(to right, ${[0, 60, 120, 180, 240, 300, 360]
          .map((stop) => `hsl(${stop} 100% 50%)`)
          .join(", ")})`
      : `linear-gradient(to right, ${colorToCss(color, 0)}, ${colorToCss(color, 1)})`;

  const slider = (
    <SliderPrimitive.Control className="relative flex h-3 w-full touch-none items-center select-none">
      <SliderPrimitive.Track
        className="relative h-3 w-full cursor-pointer rounded-full"
        style={{ background }}
      >
        <span aria-hidden className={cn(HAIRLINE, "rounded-full")} />
      </SliderPrimitive.Track>
      <SliderPrimitive.Thumb
        getAriaLabel={() => label}
        className={cn(THUMB, THUMB_RING, "top-1/2")}
        style={{ backgroundColor: colorToCss(color) }}
      />
    </SliderPrimitive.Control>
  );

  return (
    <SliderPrimitive.Root
      value={value}
      min={0}
      max={max}
      step={1}
      className="w-full"
      onValueChange={(next) => {
        const channelValue = Array.isArray(next) ? next[0] : next;
        setColor(
          channel === "hue"
            ? { ...color, hue: channelValue }
            : { ...color, alpha: channelValue / 100 }
        );
      }}
    >
      {channel === "hue" ? (
        slider
      ) : (
        <div className={cn("relative h-3 w-full rounded-full", CHECKERBOARD)}>
          {slider}
        </div>
      )}
    </SliderPrimitive.Root>
  );
}

/** The current colour, a round swatch. */
function ColorSwatch({ className }: { className?: string }) {
  const { color } = useColorPicker();

  return (
    <span
      aria-hidden
      className={cn(
        "block size-8 shrink-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.06)]",
        className
      )}
      style={{ backgroundColor: colorToCss(color) }}
    />
  );
}

/** The hex field on the input's tertiary surface; commits on Enter or blur. */
function ColorField() {
  const { color, setColor } = useColorPicker();

  const hex = colorToHex(color, false);
  const [text, setText] = React.useState(hex);
  React.useEffect(() => setText(hex), [hex]);

  function commit() {
    const parsed = parseColor(text);
    if (!parsed) {
      setText(hex);
      return;
    }

    setColor({ ...parsed, alpha: color.alpha });
  }

  return (
    <input
      type="text"
      dir="ltr"
      spellCheck={false}
      autoComplete="off"
      aria-label="Hex color"
      value={text}
      onChange={(event) => setText(event.target.value)}
      onBlur={commit}
      onKeyDown={(event) => {
        // React Aria's field commits on blur only; Enter commits it too, and
        // focus stays in the field.
        if (event.key !== "Enter") {
          return;
        }
        event.preventDefault();
        commit();
      }}
      className={cn(
        "h-8 w-full min-w-0 rounded-[10px] bg-[var(--surface-strong,var(--muted))] px-2.5 font-mono text-[13px] text-foreground uppercase",
        "ring-2 ring-transparent ring-inset outline-none transition-[background-color,box-shadow] duration-150 ease-[ease]",
        // cobalt-allow-focus-within -- text input (hex field)
        "focus:bg-popover focus:ring-ring",
        "motion-reduce:transition-none"
      )}
    />
  );
}

/** Preset colours in a row under the editor. */
function ColorSwatches({ swatches }: { swatches: string[] }) {
  const { color, setColor } = useColorPicker();
  const current = colorToHex(color, true);

  return (
    <div
      role="group"
      aria-label="Presets"
      className="grid grid-cols-7 gap-1.5 pt-0.5"
    >
      {swatches.map((swatch) => {
        const parsed = parseColor(swatch);
        const selected =
          parsed !== null && colorToHex(parsed, true) === current;

        return (
          <button
            key={swatch}
            type="button"
            aria-label={swatch}
            aria-pressed={selected}
            data-selected={selected || undefined}
            onClick={() => {
              if (parsed) {
                setColor({ ...parsed, alpha: color.alpha });
              }
            }}
            className={cn(
              "group/swatch relative aspect-square w-full cursor-pointer rounded-full outline-none transition-[scale] duration-150 ease-out hover:scale-110 motion-reduce:transition-none",
              FOCUS_RING
            )}
          >
            <span
              aria-hidden
              className="block size-full rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)]"
              style={{ backgroundColor: swatch }}
            />
            {/* Selected: a white ring inside a dark hairline reads on any colour. */}
            <span
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-full opacity-0 shadow-[inset_0_0_0_2px_#fff,inset_0_0_0_3px_rgb(0_0_0/0.25)] transition-opacity duration-150 group-data-[selected]/swatch:opacity-100 motion-reduce:transition-none"
            />
          </button>
        );
      })}
    </div>
  );
}

interface EditorProps {
  showAlpha?: boolean;
  swatches?: string[];
  className?: string;
}

/** The controls, bound to the nearest ColorPicker. */
function Editor({ showAlpha = false, swatches, className }: EditorProps) {
  return (
    <div
      className={cn("flex w-[228px] max-w-full flex-col gap-2.5", className)}
    >
      <ColorArea />

      <ColorSlider channel="hue" />

      {showAlpha ? <ColorSlider channel="alpha" /> : null}

      <div className="flex items-center gap-2">
        <ColorSwatch />
        <ColorField />
      </div>

      {swatches?.length ? <ColorSwatches swatches={swatches} /> : null}
    </div>
  );
}

export interface ColorPickerProps {
  /** Controlled colour: hex, rgb() or hsl(). */
  value?: string;
  /** Initial colour, when uncontrolled. Default #000000. */
  defaultValue?: string;
  /** Called with the colour as a hex string. */
  onChange?: (value: string) => void;
  /** Add an opacity rail. Default false. */
  showAlpha?: boolean;
  /** Preset colours, as CSS colour strings, in a row under the editor. */
  swatches?: string[];
  /** Classes on the editor, e.g. a width. Default 228px. */
  className?: string;
}

function ColorPickerRoot({
  value,
  defaultValue,
  onChange,
  children,
}: ColorPickerProps & { children: React.ReactNode }) {
  const [internal, setInternal] = React.useState<ColorPickerColor>(
    () => parseColor(defaultValue ?? DEFAULT_COLOR) ?? BLACK
  );

  const controlled = value === undefined ? null : parseColor(value);
  // Keep the HSB state, and with it the hue, when the parent hands back the
  // hex we just emitted, so the hue still survives a trip through grey.
  const color =
    controlled && colorToHex(internal, true) !== colorToHex(controlled, true)
      ? controlled
      : internal;

  const setColor = React.useCallback(
    (next: ColorPickerColor) => {
      setInternal(next);
      onChange?.(colorToHex(next));
    },
    [onChange]
  );

  return (
    <ColorPickerContext.Provider value={{ color, setColor }}>
      {children}
    </ColorPickerContext.Provider>
  );
}

export function ColorPicker({
  showAlpha,
  swatches,
  className,
  ...props
}: ColorPickerProps) {
  return (
    <ColorPickerRoot {...props}>
      <Editor showAlpha={showAlpha} swatches={swatches} className={className} />
    </ColorPickerRoot>
  );
}

export interface ColorWellProps extends ColorPickerProps {
  /** Names the swatch button and its popover. Default "Color". */
  "aria-label"?: string;
  /** Which side the editor opens on. Default "bottom". */
  side?: PopoverSide;
  /** How the editor lines up with the swatch. Default "start". */
  align?: PopoverAlign;
  /** Classes on the swatch button, e.g. a size. Default 32px. */
  wellClassName?: string;
  /** Classes on the popover surface, e.g. a theme scope. */
  popoverClassName?: string;
}

type PopoverSide = "top" | "bottom" | "left" | "right";
type PopoverAlign = "start" | "center" | "end";

/** A round swatch that opens the editor in a popover. */
export function ColorWell({
  "aria-label": ariaLabel = "Color",
  side = "bottom",
  align = "start",
  showAlpha,
  swatches,
  className,
  wellClassName,
  popoverClassName,
  ...props
}: ColorWellProps) {
  return (
    <ColorPickerRoot {...props}>
      <Popover>
        <PopoverTrigger
          aria-label={ariaLabel}
          className={cn(
            "relative size-8 shrink-0 cursor-pointer rounded-full outline-none transition-[scale] duration-150 ease-out hover:scale-110 motion-reduce:transition-none",
            FOCUS_RING,
            wellClassName
          )}
        >
          <ColorSwatch className="size-full rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)]" />
        </PopoverTrigger>
        <PopoverContent
          aria-label={ariaLabel}
          side={side}
          align={align}
          className={cn(
            "w-[248px] gap-0 rounded-[24px] p-2.5",
            popoverClassName
          )}
        >
          <Editor
            showAlpha={showAlpha}
            swatches={swatches}
            className={cn("w-full", className)}
          />
        </PopoverContent>
      </Popover>
    </ColorPickerRoot>
  );
}