Dice UI
Components

Color Picker

A color picker component that allows users to select colors using various input methods.

import {
  ColorPicker,
  ColorPickerAlphaSlider,
  ColorPickerArea,
  ColorPickerContent,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHueSlider,
  ColorPickerInput,
  ColorPickerSwatch,
  ColorPickerTrigger,
} from "@/components/ui/color-picker";
 
export function ColorPickerDemo() {
  return (
    <ColorPicker defaultFormat="hex" defaultValue="#3b82f6">
      <ColorPickerTrigger asChild>
        <ColorPickerSwatch />
      </ColorPickerTrigger>
      <ColorPickerContent>
        <ColorPickerArea />
        <div className="flex items-center gap-2">
          <ColorPickerEyeDropper />
          <div className="flex flex-1 flex-col gap-2">
            <ColorPickerHueSlider />
            <ColorPickerAlphaSlider />
          </div>
        </div>
        <div className="flex items-center gap-2">
          <ColorPickerFormatSelect />
          <ColorPickerInput />
        </div>
      </ColorPickerContent>
    </ColorPicker>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/color-picker"
pnpm dlx shadcn@latest add "https://diceui.com/r/color-picker"
yarn dlx shadcn@latest add "https://diceui.com/r/color-picker"
bun x shadcn@latest add "https://diceui.com/r/color-picker"

Manual

Install the following dependencies:

npm install @radix-ui/react-popover @radix-ui/react-select @radix-ui/react-slider @radix-ui/react-slot class-variance-authority lucide-react
pnpm add @radix-ui/react-popover @radix-ui/react-select @radix-ui/react-slider @radix-ui/react-slot class-variance-authority lucide-react
yarn add @radix-ui/react-popover @radix-ui/react-select @radix-ui/react-slider @radix-ui/react-slot class-variance-authority lucide-react
bun add @radix-ui/react-popover @radix-ui/react-select @radix-ui/react-slider @radix-ui/react-slot class-variance-authority lucide-react

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import * as SliderPrimitive from "@radix-ui/react-slider";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PipetteIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
 
/**
 * @see https://gist.github.com/bkrmendy/f4582173f50fab209ddfef1377ab31e3
 */
interface EyeDropper {
  open: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
}
 
declare global {
  interface Window {
    EyeDropper?: {
      new (): EyeDropper;
    };
  }
}
 
const colorFormats = ["hex", "rgb", "hsl", "hsb"] as const;
type ColorFormat = (typeof colorFormats)[number];
 
interface ColorValue {
  r: number;
  g: number;
  b: number;
  a: number;
}
 
interface HSVColorValue {
  h: number;
  s: number;
  v: number;
  a: number;
}
 
function hexToRgb(hex: string, alpha?: number): ColorValue {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result
    ? {
        r: Number.parseInt(result[1] ?? "0", 16),
        g: Number.parseInt(result[2] ?? "0", 16),
        b: Number.parseInt(result[3] ?? "0", 16),
        a: alpha ?? 1,
      }
    : { r: 0, g: 0, b: 0, a: alpha ?? 1 };
}
 
function rgbToHex(color: ColorValue): string {
  const toHex = (n: number) => {
    const hex = Math.round(n).toString(16);
    return hex.length === 1 ? `0${hex}` : hex;
  };
  return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
}
 
function rgbToHsv(color: ColorValue): HSVColorValue {
  const r = color.r / 255;
  const g = color.g / 255;
  const b = color.b / 255;
 
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const diff = max - min;
 
  let h = 0;
  if (diff !== 0) {
    switch (max) {
      case r:
        h = ((g - b) / diff) % 6;
        break;
      case g:
        h = (b - r) / diff + 2;
        break;
      case b:
        h = (r - g) / diff + 4;
        break;
    }
  }
  h = Math.round(h * 60);
  if (h < 0) h += 360;
 
  const s = max === 0 ? 0 : diff / max;
  const v = max;
 
  return {
    h,
    s: Math.round(s * 100),
    v: Math.round(v * 100),
    a: color.a,
  };
}
 
function hsvToRgb(hsv: HSVColorValue): ColorValue {
  const h = hsv.h / 360;
  const s = hsv.s / 100;
  const v = hsv.v / 100;
 
  const i = Math.floor(h * 6);
  const f = h * 6 - i;
  const p = v * (1 - s);
  const q = v * (1 - f * s);
  const t = v * (1 - (1 - f) * s);
 
  let r: number;
  let g: number;
  let b: number;
 
  switch (i % 6) {
    case 0: {
      r = v;
      g = t;
      b = p;
      break;
    }
    case 1: {
      r = q;
      g = v;
      b = p;
      break;
    }
    case 2: {
      r = p;
      g = v;
      b = t;
      break;
    }
    case 3: {
      r = p;
      g = q;
      b = v;
      break;
    }
    case 4: {
      r = t;
      g = p;
      b = v;
      break;
    }
    case 5: {
      r = v;
      g = p;
      b = q;
      break;
    }
    default: {
      r = 0;
      g = 0;
      b = 0;
    }
  }
 
  return {
    r: Math.round(r * 255),
    g: Math.round(g * 255),
    b: Math.round(b * 255),
    a: hsv.a,
  };
}
 
function colorToString(color: ColorValue, format: ColorFormat = "hex"): string {
  switch (format) {
    case "hex":
      return rgbToHex(color);
    case "rgb":
      return color.a < 1
        ? `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`
        : `rgb(${color.r}, ${color.g}, ${color.b})`;
    case "hsl": {
      const hsl = rgbToHsl(color);
      return color.a < 1
        ? `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${color.a})`
        : `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
    }
    case "hsb": {
      const hsv = rgbToHsv(color);
      return color.a < 1
        ? `hsba(${hsv.h}, ${hsv.s}%, ${hsv.v}%, ${color.a})`
        : `hsb(${hsv.h}, ${hsv.s}%, ${hsv.v}%)`;
    }
    default:
      return rgbToHex(color);
  }
}
 
function rgbToHsl(color: ColorValue) {
  const r = color.r / 255;
  const g = color.g / 255;
  const b = color.b / 255;
 
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const diff = max - min;
  const sum = max + min;
 
  const l = sum / 2;
 
  let h = 0;
  let s = 0;
 
  if (diff !== 0) {
    s = l > 0.5 ? diff / (2 - sum) : diff / sum;
 
    if (max === r) {
      h = (g - b) / diff + (g < b ? 6 : 0);
    } else if (max === g) {
      h = (b - r) / diff + 2;
    } else if (max === b) {
      h = (r - g) / diff + 4;
    }
    h /= 6;
  }
 
  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}
 
function hslToRgb(
  hsl: { h: number; s: number; l: number },
  alpha = 1,
): ColorValue {
  const h = hsl.h / 360;
  const s = hsl.s / 100;
  const l = hsl.l / 100;
 
  const c = (1 - Math.abs(2 * l - 1)) * s;
  const x = c * (1 - Math.abs(((h * 6) % 2) - 1));
  const m = l - c / 2;
 
  let r = 0;
  let g = 0;
  let b = 0;
 
  if (h >= 0 && h < 1 / 6) {
    r = c;
    g = x;
    b = 0;
  } else if (h >= 1 / 6 && h < 2 / 6) {
    r = x;
    g = c;
    b = 0;
  } else if (h >= 2 / 6 && h < 3 / 6) {
    r = 0;
    g = c;
    b = x;
  } else if (h >= 3 / 6 && h < 4 / 6) {
    r = 0;
    g = x;
    b = c;
  } else if (h >= 4 / 6 && h < 5 / 6) {
    r = x;
    g = 0;
    b = c;
  } else if (h >= 5 / 6 && h < 1) {
    r = c;
    g = 0;
    b = x;
  }
 
  return {
    r: Math.round((r + m) * 255),
    g: Math.round((g + m) * 255),
    b: Math.round((b + m) * 255),
    a: alpha,
  };
}
 
function parseColorString(value: string): ColorValue | null {
  const trimmed = value.trim();
 
  // Parse hex colors
  if (trimmed.startsWith("#")) {
    const hexMatch = trimmed.match(/^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$/);
    if (hexMatch) {
      return hexToRgb(trimmed);
    }
  }
 
  // Parse rgb/rgba colors
  const rgbMatch = trimmed.match(
    /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/,
  );
  if (rgbMatch) {
    return {
      r: Number.parseInt(rgbMatch[1] ?? "0", 10),
      g: Number.parseInt(rgbMatch[2] ?? "0", 10),
      b: Number.parseInt(rgbMatch[3] ?? "0", 10),
      a: rgbMatch[4] ? Number.parseFloat(rgbMatch[4]) : 1,
    };
  }
 
  // Parse hsl/hsla colors
  const hslMatch = trimmed.match(
    /^hsla?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([\d.]+))?\s*\)$/,
  );
  if (hslMatch) {
    const h = Number.parseInt(hslMatch[1] ?? "0", 10);
    const s = Number.parseInt(hslMatch[2] ?? "0", 10) / 100;
    const l = Number.parseInt(hslMatch[3] ?? "0", 10) / 100;
    const a = hslMatch[4] ? Number.parseFloat(hslMatch[4]) : 1;
 
    // Convert HSL to RGB
    const c = (1 - Math.abs(2 * l - 1)) * s;
    const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
    const m = l - c / 2;
 
    let r = 0;
    let g = 0;
    let b = 0;
 
    if (h >= 0 && h < 60) {
      r = c;
      g = x;
      b = 0;
    } else if (h >= 60 && h < 120) {
      r = x;
      g = c;
      b = 0;
    } else if (h >= 120 && h < 180) {
      r = 0;
      g = c;
      b = x;
    } else if (h >= 180 && h < 240) {
      r = 0;
      g = x;
      b = c;
    } else if (h >= 240 && h < 300) {
      r = x;
      g = 0;
      b = c;
    } else if (h >= 300 && h < 360) {
      r = c;
      g = 0;
      b = x;
    }
 
    return {
      r: Math.round((r + m) * 255),
      g: Math.round((g + m) * 255),
      b: Math.round((b + m) * 255),
      a,
    };
  }
 
  // Parse hsb/hsba colors
  const hsbMatch = trimmed.match(
    /^hsba?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*([\d.]+))?\s*\)$/,
  );
  if (hsbMatch) {
    const h = Number.parseInt(hsbMatch[1] ?? "0", 10);
    const s = Number.parseInt(hsbMatch[2] ?? "0", 10);
    const v = Number.parseInt(hsbMatch[3] ?? "0", 10);
    const a = hsbMatch[4] ? Number.parseFloat(hsbMatch[4]) : 1;
 
    return hsvToRgb({ h, s, v, a });
  }
 
  return null;
}
 
type Direction = "ltr" | "rtl";
 
const DirectionContext = React.createContext<Direction | undefined>(undefined);
 
function useDirection(dirProp?: Direction): Direction {
  const contextDir = React.useContext(DirectionContext);
  return dirProp ?? contextDir ?? "ltr";
}
 
function useLazyRef<T>(fn: () => T) {
  const ref = React.useRef<T | null>(null);
 
  if (ref.current === null) {
    ref.current = fn();
  }
 
  return ref as React.RefObject<T>;
}
 
interface ColorPickerStoreState {
  color: ColorValue;
  hsv: HSVColorValue;
  open: boolean;
  format: ColorFormat;
}
 
interface ColorPickerStoreCallbacks {
  onColorChange?: (colorString: string) => void;
  onOpenChange?: (open: boolean) => void;
  onFormatChange?: (format: ColorFormat) => void;
}
 
interface ColorPickerStore {
  subscribe: (cb: () => void) => () => void;
  getState: () => ColorPickerStoreState;
  setColor: (value: ColorValue) => void;
  setHsv: (value: HSVColorValue) => void;
  setOpen: (value: boolean) => void;
  setFormat: (value: ColorFormat) => void;
  notify: () => void;
}
 
function createColorPickerStore(
  listenersRef: React.RefObject<Set<() => void>>,
  stateRef: React.RefObject<ColorPickerStoreState>,
  callbacks?: ColorPickerStoreCallbacks,
): ColorPickerStore {
  const store: ColorPickerStore = {
    subscribe: (cb) => {
      if (listenersRef.current) {
        listenersRef.current.add(cb);
        return () => listenersRef.current?.delete(cb);
      }
      return () => {};
    },
    getState: () =>
      stateRef.current || {
        color: { r: 0, g: 0, b: 0, a: 1 },
        hsv: { h: 0, s: 0, v: 0, a: 1 },
        open: false,
        format: "hex" as ColorFormat,
      },
    setColor: (value: ColorValue) => {
      if (!stateRef.current) return;
      if (Object.is(stateRef.current.color, value)) return;
 
      const prevState = { ...stateRef.current };
      stateRef.current.color = value;
 
      if (callbacks?.onColorChange) {
        const colorString = colorToString(value, prevState.format);
        callbacks.onColorChange(colorString);
      }
 
      store.notify();
    },
    setHsv: (value: HSVColorValue) => {
      if (!stateRef.current) return;
      if (Object.is(stateRef.current.hsv, value)) return;
 
      const prevState = { ...stateRef.current };
      stateRef.current.hsv = value;
 
      if (callbacks?.onColorChange) {
        const colorValue = hsvToRgb(value);
        const colorString = colorToString(colorValue, prevState.format);
        callbacks.onColorChange(colorString);
      }
 
      store.notify();
    },
    setOpen: (value: boolean) => {
      if (!stateRef.current) return;
      if (Object.is(stateRef.current.open, value)) return;
 
      stateRef.current.open = value;
 
      if (callbacks?.onOpenChange) {
        callbacks.onOpenChange(value);
      }
 
      store.notify();
    },
    setFormat: (value: ColorFormat) => {
      if (!stateRef.current) return;
      if (Object.is(stateRef.current.format, value)) return;
 
      stateRef.current.format = value;
 
      if (callbacks?.onFormatChange) {
        callbacks.onFormatChange(value);
      }
 
      store.notify();
    },
    notify: () => {
      if (listenersRef.current) {
        for (const cb of listenersRef.current) {
          cb();
        }
      }
    },
  };
 
  return store;
}
 
function useColorPickerStoreContext(consumerName: string) {
  const context = React.useContext(ColorPickerStoreContext);
  if (!context) {
    throw new Error(
      `\`${consumerName}\` must be used within \`ColorPickerRoot\``,
    );
  }
  return context;
}
 
function useColorPickerStore<U>(
  selector: (state: ColorPickerStoreState) => U,
): U {
  const store = useColorPickerStoreContext("useColorPickerStoreSelector");
 
  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
interface ColorPickerContextValue {
  dir: Direction;
  disabled?: boolean;
  inline?: boolean;
  readOnly?: boolean;
  required?: boolean;
}
 
const ColorPickerStoreContext = React.createContext<ColorPickerStore | null>(
  null,
);
const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(
  null,
);
 
function useColorPickerContext(consumerName: string) {
  const context = React.useContext(ColorPickerContext);
  if (!context) {
    throw new Error(
      `\`${consumerName}\` must be used within \`ColorPickerRoot\``,
    );
  }
  return context;
}
 
interface ColorPickerRootProps
  extends Omit<React.ComponentProps<"div">, "onValueChange">,
    Pick<
      React.ComponentProps<typeof Popover>,
      "defaultOpen" | "open" | "onOpenChange" | "modal"
    > {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  dir?: Direction;
  format?: ColorFormat;
  defaultFormat?: ColorFormat;
  onFormatChange?: (format: ColorFormat) => void;
  name?: string;
  asChild?: boolean;
  disabled?: boolean;
  inline?: boolean;
  readOnly?: boolean;
  required?: boolean;
}
 
function ColorPickerRoot(props: ColorPickerRootProps) {
  const {
    value: valueProp,
    defaultValue = "#000000",
    onValueChange,
    format: formatProp,
    defaultFormat = "hex",
    onFormatChange,
    defaultOpen,
    open: openProp,
    onOpenChange,
    name,
    disabled,
    inline,
    readOnly,
    required,
    ...rootProps
  } = props;
 
  const initialColor = React.useMemo(() => {
    const colorString = valueProp ?? defaultValue;
    const color = hexToRgb(colorString);
 
    return {
      color,
      hsv: rgbToHsv(color),
      open: openProp ?? defaultOpen ?? false,
      format: formatProp ?? defaultFormat,
    };
  }, [
    valueProp,
    defaultValue,
    formatProp,
    defaultFormat,
    openProp,
    defaultOpen,
  ]);
 
  const stateRef = useLazyRef(() => initialColor);
  const listenersRef = useLazyRef(() => new Set<() => void>());
 
  const storeCallbacks = React.useMemo<ColorPickerStoreCallbacks>(
    () => ({
      onColorChange: onValueChange,
      onOpenChange: onOpenChange,
      onFormatChange: onFormatChange,
    }),
    [onValueChange, onOpenChange, onFormatChange],
  );
 
  const store = React.useMemo(
    () => createColorPickerStore(listenersRef, stateRef, storeCallbacks),
    [listenersRef, stateRef, storeCallbacks],
  );
 
  return (
    <ColorPickerStoreContext.Provider value={store}>
      <ColorPickerRootImpl
        {...rootProps}
        value={valueProp}
        defaultOpen={defaultOpen}
        open={openProp}
        onOpenChange={onOpenChange}
        name={name}
        disabled={disabled}
        inline={inline}
        readOnly={readOnly}
        required={required}
      />
    </ColorPickerStoreContext.Provider>
  );
}
 
interface ColorPickerRootImplProps
  extends Omit<
    ColorPickerRootProps,
    | "defaultValue"
    | "onValueChange"
    | "format"
    | "defaultFormat"
    | "onFormatChange"
  > {}
 
function ColorPickerRootImpl(props: ColorPickerRootImplProps) {
  const {
    value: valueProp,
    dir: dirProp,
    defaultOpen,
    open: openProp,
    onOpenChange,
    name,
    ref,
    asChild,
    disabled,
    inline,
    modal,
    readOnly,
    required,
    ...rootProps
  } = props;
 
  const store = useColorPickerStoreContext("ColorPickerRootImpl");
 
  const dir = useDirection(dirProp);
 
  const [formTrigger, setFormTrigger] = React.useState<HTMLDivElement | null>(
    null,
  );
  const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
 
  const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
 
  React.useEffect(() => {
    if (valueProp !== undefined) {
      const currentState = store.getState();
      const color = hexToRgb(valueProp, currentState.color.a);
      const hsv = rgbToHsv(color);
      store.setColor(color);
      store.setHsv(hsv);
    }
  }, [valueProp, store]);
 
  React.useEffect(() => {
    if (openProp !== undefined) {
      store.setOpen(openProp);
    }
  }, [openProp, store]);
 
  const contextValue = React.useMemo<ColorPickerContextValue>(
    () => ({
      dir,
      disabled,
      inline,
      readOnly,
      required,
    }),
    [dir, disabled, inline, readOnly, required],
  );
 
  const value = useColorPickerStore((state) => rgbToHex(state.color));
 
  const open = useColorPickerStore((state) => state.open);
 
  const onPopoverOpenChange = React.useCallback(
    (newOpen: boolean) => {
      store.setOpen(newOpen);
      onOpenChange?.(newOpen);
    },
    [store.setOpen, onOpenChange],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  if (inline) {
    return (
      <ColorPickerContext.Provider value={contextValue}>
        <RootPrimitive {...rootProps} ref={composedRef} />
        {isFormControl && (
          <VisuallyHiddenInput
            type="hidden"
            control={formTrigger}
            name={name}
            value={value}
            disabled={disabled}
            readOnly={readOnly}
            required={required}
          />
        )}
      </ColorPickerContext.Provider>
    );
  }
 
  return (
    <ColorPickerContext.Provider value={contextValue}>
      <Popover
        defaultOpen={defaultOpen}
        open={open}
        onOpenChange={onPopoverOpenChange}
        modal={modal}
      >
        <RootPrimitive {...rootProps} ref={composedRef} />
        {isFormControl && (
          <VisuallyHiddenInput
            type="hidden"
            control={formTrigger}
            name={name}
            value={value}
            disabled={disabled}
            readOnly={readOnly}
            required={required}
          />
        )}
      </Popover>
    </ColorPickerContext.Provider>
  );
}
 
interface ColorPickerTriggerProps
  extends React.ComponentProps<typeof PopoverTrigger> {}
 
function ColorPickerTrigger(props: ColorPickerTriggerProps) {
  const { asChild, ...triggerProps } = props;
  const context = useColorPickerContext("ColorPickerTrigger");
 
  const TriggerPrimitive = asChild ? Slot : Button;
 
  return (
    <PopoverTrigger asChild disabled={context.disabled}>
      <TriggerPrimitive data-slot="color-picker-trigger" {...triggerProps} />
    </PopoverTrigger>
  );
}
 
interface ColorPickerContentProps
  extends React.ComponentProps<typeof PopoverContent> {}
 
function ColorPickerContent(props: ColorPickerContentProps) {
  const { asChild, className, children, ...popoverContentProps } = props;
  const context = useColorPickerContext("ColorPickerContent");
 
  if (context.inline) {
    const ContentPrimitive = asChild ? Slot : "div";
 
    return (
      <ContentPrimitive
        data-slot="color-picker-content"
        {...popoverContentProps}
        className={cn("flex w-[340px] flex-col gap-4 p-4", className)}
      >
        {children}
      </ContentPrimitive>
    );
  }
 
  return (
    <PopoverContent
      data-slot="color-picker-content"
      asChild={asChild}
      {...popoverContentProps}
      className={cn("flex w-[340px] flex-col gap-4 p-4", className)}
    >
      {children}
    </PopoverContent>
  );
}
 
interface ColorPickerAreaProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function ColorPickerArea(props: ColorPickerAreaProps) {
  const { asChild, className, ref, ...areaProps } = props;
  const context = useColorPickerContext("ColorPickerArea");
  const store = useColorPickerStoreContext("ColorPickerArea");
 
  const hsv = useColorPickerStore((state) => state.hsv);
 
  const isDraggingRef = React.useRef(false);
  const areaRef = React.useRef<HTMLDivElement>(null);
  const composedRef = useComposedRefs(ref, areaRef);
 
  const updateColorFromPosition = React.useCallback(
    (clientX: number, clientY: number) => {
      if (!areaRef.current) return;
 
      const rect = areaRef.current.getBoundingClientRect();
      const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
      const y = Math.max(
        0,
        Math.min(1, 1 - (clientY - rect.top) / rect.height),
      );
 
      const newHsv: HSVColorValue = {
        h: hsv?.h ?? 0,
        s: Math.round(x * 100),
        v: Math.round(y * 100),
        a: hsv?.a ?? 1,
      };
 
      store.setHsv(newHsv);
      store.setColor(hsvToRgb(newHsv));
    },
    [hsv, store],
  );
 
  const onPointerDown = React.useCallback(
    (event: React.PointerEvent) => {
      if (context.disabled) return;
 
      isDraggingRef.current = true;
      areaRef.current?.setPointerCapture(event.pointerId);
      updateColorFromPosition(event.clientX, event.clientY);
    },
    [context.disabled, updateColorFromPosition],
  );
 
  const onPointerMove = React.useCallback(
    (event: React.PointerEvent) => {
      if (isDraggingRef.current) {
        updateColorFromPosition(event.clientX, event.clientY);
      }
    },
    [updateColorFromPosition],
  );
 
  const onPointerUp = React.useCallback((event: React.PointerEvent) => {
    isDraggingRef.current = false;
    areaRef.current?.releasePointerCapture(event.pointerId);
  }, []);
 
  const hue = hsv?.h ?? 0;
  const backgroundHue = hsvToRgb({ h: hue, s: 100, v: 100, a: 1 });
 
  const AreaPrimitive = asChild ? Slot : "div";
 
  return (
    <AreaPrimitive
      data-slot="color-picker-area"
      {...areaProps}
      className={cn(
        "relative h-40 w-full cursor-crosshair touch-none rounded-sm border",
        context.disabled && "pointer-events-none opacity-50",
        className,
      )}
      ref={composedRef}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
    >
      <div className="absolute inset-0 overflow-hidden rounded-sm">
        <div
          className="absolute inset-0"
          style={{
            backgroundColor: `rgb(${backgroundHue.r}, ${backgroundHue.g}, ${backgroundHue.b})`,
          }}
        />
        <div
          className="absolute inset-0"
          style={{
            background: "linear-gradient(to right, #fff, transparent)",
          }}
        />
        <div
          className="absolute inset-0"
          style={{
            background: "linear-gradient(to bottom, transparent, #000)",
          }}
        />
      </div>
      <div
        className="-translate-x-1/2 -translate-y-1/2 absolute h-3 w-3 rounded-full border-2 border-white shadow-sm"
        style={{
          left: `${hsv?.s ?? 0}%`,
          top: `${100 - (hsv?.v ?? 0)}%`,
        }}
      />
    </AreaPrimitive>
  );
}
 
interface ColorPickerHueSliderProps
  extends React.ComponentProps<typeof SliderPrimitive.Root> {}
 
function ColorPickerHueSlider(props: ColorPickerHueSliderProps) {
  const { className, ...sliderProps } = props;
  const context = useColorPickerContext("ColorPickerHueSlider");
  const store = useColorPickerStoreContext("ColorPickerHueSlider");
 
  const hsv = useColorPickerStore((state) => state.hsv);
 
  const onValueChange = React.useCallback(
    (values: number[]) => {
      const newHsv: HSVColorValue = {
        h: values[0] ?? 0,
        s: hsv?.s ?? 0,
        v: hsv?.v ?? 0,
        a: hsv?.a ?? 1,
      };
      store.setHsv(newHsv);
      store.setColor(hsvToRgb(newHsv));
    },
    [hsv, store],
  );
 
  return (
    <SliderPrimitive.Root
      data-slot="color-picker-hue-slider"
      {...sliderProps}
      max={360}
      step={1}
      className={cn(
        "relative flex w-full touch-none select-none items-center",
        className,
      )}
      value={[hsv?.h ?? 0]}
      onValueChange={onValueChange}
      disabled={context.disabled}
    >
      <SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-gradient-to-r from-red-500 via-blue-500 via-cyan-500 via-green-500 via-purple-500 via-yellow-500 to-red-500">
        <SliderPrimitive.Range className="absolute h-full" />
      </SliderPrimitive.Track>
      <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
    </SliderPrimitive.Root>
  );
}
 
interface ColorPickerAlphaSliderProps
  extends React.ComponentProps<typeof SliderPrimitive.Root> {}
 
function ColorPickerAlphaSlider(props: ColorPickerAlphaSliderProps) {
  const { className, ...sliderProps } = props;
  const context = useColorPickerContext("ColorPickerAlphaSlider");
  const store = useColorPickerStoreContext("ColorPickerAlphaSlider");
 
  const color = useColorPickerStore((state) => state.color);
  const hsv = useColorPickerStore((state) => state.hsv);
 
  const onValueChange = React.useCallback(
    (values: number[]) => {
      const alpha = (values[0] ?? 0) / 100;
      const newColor = { ...color, a: alpha };
      const newHsv = { ...hsv, a: alpha };
      store.setColor(newColor);
      store.setHsv(newHsv);
    },
    [color, hsv, store],
  );
 
  const gradientColor = `rgb(${color?.r ?? 0}, ${color?.g ?? 0}, ${color?.b ?? 0})`;
 
  return (
    <SliderPrimitive.Root
      data-slot="color-picker-alpha-slider"
      {...sliderProps}
      max={100}
      step={1}
      disabled={context.disabled}
      className={cn(
        "relative flex w-full touch-none select-none items-center",
        className,
      )}
      value={[Math.round((color?.a ?? 1) * 100)]}
      onValueChange={onValueChange}
    >
      <SliderPrimitive.Track
        className="relative h-3 w-full grow overflow-hidden rounded-full"
        style={{
          background:
            "linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)",
          backgroundSize: "8px 8px",
          backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
        }}
      >
        <div
          className="absolute inset-0 rounded-full"
          style={{
            background: `linear-gradient(to right, transparent, ${gradientColor})`,
          }}
        />
        <SliderPrimitive.Range className="absolute h-full" />
      </SliderPrimitive.Track>
      <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
    </SliderPrimitive.Root>
  );
}
 
interface ColorPickerSwatchProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function ColorPickerSwatch(props: ColorPickerSwatchProps) {
  const { asChild, className, ...swatchProps } = props;
  const context = useColorPickerContext("ColorPickerSwatch");
 
  const color = useColorPickerStore((state) => state.color);
  const format = useColorPickerStore((state) => state.format);
 
  const backgroundStyle = React.useMemo(() => {
    if (!color) {
      return {
        background:
          "linear-gradient(to bottom right, transparent calc(50% - 1px), hsl(var(--destructive)) calc(50% - 1px) calc(50% + 1px), transparent calc(50% + 1px)) no-repeat",
      };
    }
 
    const colorString = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
 
    if (color.a < 1) {
      return {
        background: `linear-gradient(${colorString}, ${colorString}), repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0% 50% / 8px 8px`,
      };
    }
 
    return {
      backgroundColor: colorString,
    };
  }, [color]);
 
  const ariaLabel = !color
    ? "No color selected"
    : `Current color: ${colorToString(color, format)}`;
 
  const SwatchPrimitive = asChild ? Slot : "div";
 
  return (
    <SwatchPrimitive
      role="img"
      aria-label={ariaLabel}
      data-slot="color-picker-swatch"
      {...swatchProps}
      className={cn(
        "box-border size-8 rounded-sm border shadow-sm",
        context.disabled && "opacity-50",
        className,
      )}
      style={{
        ...backgroundStyle,
        forcedColorAdjust: "none",
      }}
    />
  );
}
 
interface ColorPickerEyeDropperProps
  extends React.ComponentProps<typeof Button> {}
 
function ColorPickerEyeDropper(props: ColorPickerEyeDropperProps) {
  const { children, size, ...buttonProps } = props;
  const context = useColorPickerContext("ColorPickerEyeDropper");
  const store = useColorPickerStoreContext("ColorPickerEyeDropper");
 
  const color = useColorPickerStore((state) => state.color);
 
  const onEyeDropper = React.useCallback(async () => {
    if (!window.EyeDropper) return;
 
    try {
      const eyeDropper = new window.EyeDropper();
      const result = await eyeDropper.open();
 
      if (result.sRGBHex) {
        const currentAlpha = color?.a ?? 1;
        const newColor = hexToRgb(result.sRGBHex, currentAlpha);
        const newHsv = rgbToHsv(newColor);
        store.setColor(newColor);
        store.setHsv(newHsv);
      }
    } catch (error) {
      console.warn("EyeDropper error:", error);
    }
  }, [color, store]);
 
  const hasEyeDropper = typeof window !== "undefined" && !!window.EyeDropper;
 
  if (!hasEyeDropper) return null;
 
  const buttonSize = size ?? (children ? "default" : "icon");
 
  return (
    <Button
      data-slot="color-picker-eye-dropper"
      {...buttonProps}
      variant="outline"
      size={buttonSize}
      onClick={onEyeDropper}
      disabled={context.disabled}
    >
      {children ?? <PipetteIcon />}
    </Button>
  );
}
 
interface ColorPickerFormatSelectProps
  extends Omit<React.ComponentProps<typeof Select>, "value" | "onValueChange">,
    Pick<React.ComponentProps<typeof SelectTrigger>, "size" | "className"> {}
 
function ColorPickerFormatSelect(props: ColorPickerFormatSelectProps) {
  const { size, className, ...selectProps } = props;
  const context = useColorPickerContext("ColorPickerFormatSelector");
  const store = useColorPickerStoreContext("ColorPickerFormatSelector");
 
  const format = useColorPickerStore((state) => state.format);
 
  const onFormatChange = React.useCallback(
    (value: ColorFormat) => {
      store.setFormat(value);
    },
    [store],
  );
 
  return (
    <Select
      data-slot="color-picker-format-select"
      {...selectProps}
      value={format}
      onValueChange={onFormatChange}
      disabled={context.disabled}
    >
      <SelectTrigger
        data-slot="color-picker-format-select-trigger"
        size={size ?? "sm"}
        className={cn(className)}
      >
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        {colorFormats.map((format) => (
          <SelectItem key={format} value={format}>
            {format.toUpperCase()}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}
 
interface ColorPickerInputProps
  extends Omit<
    React.ComponentProps<typeof Input>,
    "value" | "onChange" | "color"
  > {
  withoutAlpha?: boolean;
}
 
function ColorPickerInput(props: ColorPickerInputProps) {
  const context = useColorPickerContext("ColorPickerInput");
  const store = useColorPickerStoreContext("ColorPickerInput");
 
  const color = useColorPickerStore((state) => state.color);
  const format = useColorPickerStore((state) => state.format);
  const hsv = useColorPickerStore((state) => state.hsv);
 
  const onColorChange = React.useCallback(
    (newColor: ColorValue) => {
      const newHsv = rgbToHsv(newColor);
      store.setColor(newColor);
      store.setHsv(newHsv);
    },
    [store],
  );
 
  if (format === "hex") {
    return (
      <HexInput
        color={color}
        onColorChange={onColorChange}
        context={context}
        {...props}
      />
    );
  }
 
  if (format === "rgb") {
    return (
      <RgbInput
        color={color}
        onColorChange={onColorChange}
        context={context}
        {...props}
      />
    );
  }
 
  if (format === "hsl") {
    return (
      <HslInput
        color={color}
        onColorChange={onColorChange}
        context={context}
        {...props}
      />
    );
  }
 
  if (format === "hsb") {
    return (
      <HsbInput
        hsv={hsv}
        onColorChange={onColorChange}
        context={context}
        {...props}
      />
    );
  }
}
 
const inputGroupItemVariants = cva(
  "h-8 [-moz-appearance:_textfield] focus-visible:z-10 focus-visible:ring-1 [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none",
  {
    variants: {
      position: {
        first: "rounded-e-none",
        middle: "-ms-px rounded-none border-l-0",
        last: "-ms-px rounded-s-none border-l-0",
        isolated: "",
      },
    },
    defaultVariants: {
      position: "isolated",
    },
  },
);
 
interface InputGroupItemProps
  extends React.ComponentProps<typeof Input>,
    VariantProps<typeof inputGroupItemVariants> {}
 
function InputGroupItem({
  className,
  position,
  ...props
}: InputGroupItemProps) {
  return (
    <Input
      data-slot="color-picker-input"
      className={cn(inputGroupItemVariants({ position }), className)}
      {...props}
    />
  );
}
 
interface FormatInputProps extends ColorPickerInputProps {
  color: ColorValue;
  onColorChange: (color: ColorValue) => void;
  context: ColorPickerContextValue;
}
 
function HexInput(props: FormatInputProps) {
  const {
    color,
    onColorChange,
    context,
    withoutAlpha,
    className,
    ...inputProps
  } = props;
 
  const hexValue = rgbToHex(color);
  const alphaValue = Math.round((color?.a ?? 1) * 100);
 
  const onHexChange = React.useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const value = event.target.value;
      const parsedColor = parseColorString(value);
      if (parsedColor) {
        onColorChange({ ...parsedColor, a: color?.a ?? 1 });
      }
    },
    [color, onColorChange],
  );
 
  const onAlphaChange = React.useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const value = Number.parseInt(event.target.value, 10);
      if (!Number.isNaN(value) && value >= 0 && value <= 100) {
        onColorChange({ ...color, a: value / 100 });
      }
    },
    [color, onColorChange],
  );
 
  if (withoutAlpha) {
    return (
      <InputGroupItem
        aria-label="Hex color value"
        position="isolated"
        {...inputProps}
        placeholder="#000000"
        className={cn("font-mono", className)}
        value={hexValue}
        onChange={onHexChange}
        disabled={context.disabled}
      />
    );
  }
 
  return (
    <div
      data-slot="color-picker-input-wrapper"
      className={cn("flex items-center", className)}
    >
      <InputGroupItem
        aria-label="Hex color value"
        position="first"
        {...inputProps}
        placeholder="#000000"
        className="flex-1 font-mono"
        value={hexValue}
        onChange={onHexChange}
        disabled={context.disabled}
      />
      <InputGroupItem
        aria-label="Alpha transparency percentage"
        position="last"
        {...inputProps}
        placeholder="100"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="100"
        className="w-14"
        value={alphaValue}
        onChange={onAlphaChange}
        disabled={context.disabled}
      />
    </div>
  );
}
 
function RgbInput(props: FormatInputProps) {
  const {
    color,
    onColorChange,
    context,
    withoutAlpha,
    className,
    ...inputProps
  } = props;
 
  const rValue = Math.round(color?.r ?? 0);
  const gValue = Math.round(color?.g ?? 0);
  const bValue = Math.round(color?.b ?? 0);
  const alphaValue = Math.round((color?.a ?? 1) * 100);
 
  const onChannelChange = React.useCallback(
    (channel: "r" | "g" | "b" | "a", max: number, isAlpha = false) =>
      (event: React.ChangeEvent<HTMLInputElement>) => {
        const value = Number.parseInt(event.target.value, 10);
        if (!Number.isNaN(value) && value >= 0 && value <= max) {
          const newValue = isAlpha ? value / 100 : value;
          onColorChange({ ...color, [channel]: newValue });
        }
      },
    [color, onColorChange],
  );
 
  return (
    <div
      data-slot="color-picker-input-wrapper"
      className={cn("flex items-center", className)}
    >
      <InputGroupItem
        aria-label="Red color component (0-255)"
        position="first"
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="255"
        className="w-14"
        value={rValue}
        onChange={onChannelChange("r", 255)}
        disabled={context.disabled}
      />
      <InputGroupItem
        aria-label="Green color component (0-255)"
        position="middle"
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="255"
        className="w-14"
        value={gValue}
        onChange={onChannelChange("g", 255)}
        disabled={context.disabled}
      />
      <InputGroupItem
        aria-label="Blue color component (0-255)"
        position={withoutAlpha ? "last" : "middle"}
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="255"
        className="w-14"
        value={bValue}
        onChange={onChannelChange("b", 255)}
        disabled={context.disabled}
      />
      {!withoutAlpha && (
        <InputGroupItem
          aria-label="Alpha transparency percentage"
          position="last"
          {...inputProps}
          placeholder="100"
          inputMode="numeric"
          pattern="[0-9]*"
          min="0"
          max="100"
          className="w-14"
          value={alphaValue}
          onChange={onChannelChange("a", 100, true)}
          disabled={context.disabled}
        />
      )}
    </div>
  );
}
 
function HslInput(props: FormatInputProps) {
  const {
    color,
    onColorChange,
    context,
    withoutAlpha,
    className,
    ...inputProps
  } = props;
 
  const hsl = React.useMemo(() => rgbToHsl(color), [color]);
  const alphaValue = Math.round((color?.a ?? 1) * 100);
 
  const onHslChannelChange = React.useCallback(
    (channel: "h" | "s" | "l", max: number) =>
      (event: React.ChangeEvent<HTMLInputElement>) => {
        const value = Number.parseInt(event.target.value, 10);
        if (!Number.isNaN(value) && value >= 0 && value <= max) {
          const newHsl = { ...hsl, [channel]: value };
          const newColor = hslToRgb(newHsl, color?.a ?? 1);
          onColorChange(newColor);
        }
      },
    [hsl, color, onColorChange],
  );
 
  const onAlphaChange = React.useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const value = Number.parseInt(event.target.value, 10);
      if (!Number.isNaN(value) && value >= 0 && value <= 100) {
        onColorChange({ ...color, a: value / 100 });
      }
    },
    [color, onColorChange],
  );
 
  return (
    <div
      data-slot="color-picker-input-wrapper"
      className={cn("flex items-center", className)}
    >
      <InputGroupItem
        aria-label="Hue degree (0-360)"
        position="first"
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="360"
        className="w-14"
        value={hsl.h}
        onChange={onHslChannelChange("h", 360)}
        disabled={context.disabled}
      />
      <InputGroupItem
        aria-label="Saturation percentage (0-100)"
        position="middle"
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="100"
        className="w-14"
        value={hsl.s}
        onChange={onHslChannelChange("s", 100)}
        disabled={context.disabled}
      />
      <InputGroupItem
        aria-label="Lightness percentage (0-100)"
        position={withoutAlpha ? "last" : "middle"}
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="100"
        className="w-14"
        value={hsl.l}
        onChange={onHslChannelChange("l", 100)}
        disabled={context.disabled}
      />
      {!withoutAlpha && (
        <InputGroupItem
          aria-label="Alpha transparency percentage"
          position="last"
          {...inputProps}
          placeholder="100"
          inputMode="numeric"
          pattern="[0-9]*"
          min="0"
          max="100"
          className="w-14"
          value={alphaValue}
          onChange={onAlphaChange}
          disabled={context.disabled}
        />
      )}
    </div>
  );
}
 
interface HsbInputProps extends Omit<FormatInputProps, "color"> {
  hsv: HSVColorValue;
}
 
function HsbInput(props: HsbInputProps) {
  const {
    hsv,
    onColorChange,
    context,
    withoutAlpha,
    className,
    ...inputProps
  } = props;
 
  const alphaValue = Math.round((hsv?.a ?? 1) * 100);
 
  const onHsvChannelChange = React.useCallback(
    (channel: "h" | "s" | "v", max: number) =>
      (event: React.ChangeEvent<HTMLInputElement>) => {
        const value = Number.parseInt(event.target.value, 10);
        if (!Number.isNaN(value) && value >= 0 && value <= max) {
          const newHsv = { ...hsv, [channel]: value };
          const newColor = hsvToRgb(newHsv);
          onColorChange(newColor);
        }
      },
    [hsv, onColorChange],
  );
 
  const onAlphaChange = React.useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const value = Number.parseInt(event.target.value, 10);
      if (!Number.isNaN(value) && value >= 0 && value <= 100) {
        const currentColor = hsvToRgb(hsv);
        onColorChange({ ...currentColor, a: value / 100 });
      }
    },
    [hsv, onColorChange],
  );
 
  return (
    <div
      data-slot="color-picker-input-wrapper"
      className={cn("flex items-center", className)}
    >
      <InputGroupItem
        aria-label="Hue degree (0-360)"
        position="first"
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="360"
        className="w-14"
        value={hsv?.h ?? 0}
        onChange={onHsvChannelChange("h", 360)}
        disabled={context.disabled}
      />
      <InputGroupItem
        aria-label="Saturation percentage (0-100)"
        position="middle"
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="100"
        className="w-14"
        value={hsv?.s ?? 0}
        onChange={onHsvChannelChange("s", 100)}
        disabled={context.disabled}
      />
      <InputGroupItem
        aria-label="Brightness percentage (0-100)"
        position={withoutAlpha ? "last" : "middle"}
        {...inputProps}
        placeholder="0"
        inputMode="numeric"
        pattern="[0-9]*"
        min="0"
        max="100"
        className="w-14"
        value={hsv?.v ?? 0}
        onChange={onHsvChannelChange("v", 100)}
        disabled={context.disabled}
      />
      {!withoutAlpha && (
        <InputGroupItem
          aria-label="Alpha transparency percentage"
          position="last"
          {...inputProps}
          placeholder="100"
          inputMode="numeric"
          pattern="[0-9]*"
          min="0"
          max="100"
          className="w-14"
          value={alphaValue}
          onChange={onAlphaChange}
          disabled={context.disabled}
        />
      )}
    </div>
  );
}
 
export {
  ColorPickerRoot as ColorPicker,
  ColorPickerTrigger,
  ColorPickerContent,
  ColorPickerArea,
  ColorPickerHueSlider,
  ColorPickerAlphaSlider,
  ColorPickerSwatch,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerInput,
  //
  ColorPickerRoot as Root,
  ColorPickerTrigger as Trigger,
  ColorPickerContent as Content,
  ColorPickerArea as Area,
  ColorPickerHueSlider as HueSlider,
  ColorPickerAlphaSlider as AlphaSlider,
  ColorPickerSwatch as Swatch,
  ColorPickerEyeDropper as EyeDropper,
  ColorPickerFormatSelect as FormatSelect,
  ColorPickerInput as Input,
  //
  useColorPickerStore as useColorPicker,
};

Layout

Import the parts, and compose them together.

import {
  ColorPicker,
  ColorPickerAlphaSlider,
  ColorPickerArea,
  ColorPickerContent,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHueSlider,
  ColorPickerInput,
  ColorPickerSwatch,
  ColorPickerTrigger,
} from "@/components/ui/color-picker";

<ColorPicker>
  <ColorPickerTrigger>
    <ColorPickerSwatch />
  </ColorPickerTrigger>
  <ColorPickerContent>
    <ColorPickerArea />
    <div className="flex items-center gap-2">
      <ColorPickerEyeDropper />
      <div className="flex flex-1 flex-col gap-2">
        <ColorPickerHueSlider />
        <ColorPickerAlphaSlider />
      </div>
    </div>
    <div className="flex items-center gap-2">
      <ColorPickerFormatSelect />
      <ColorPickerInput />
    </div>
  </ColorPickerContent>
</ColorPicker>

Examples

Inline Color Picker

Use the inline prop to render the color picker inline instead of in a popover.

Selected Color
"use client";
 
import * as React from "react";
import {
  ColorPicker,
  ColorPickerAlphaSlider,
  ColorPickerArea,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHueSlider,
  ColorPickerInput,
  ColorPickerSwatch,
} from "@/components/ui/color-picker";
 
export function ColorPickerInlineDemo() {
  const [color, setColor] = React.useState("#f59e0b");
 
  return (
    <ColorPicker
      value={color}
      onValueChange={setColor}
      inline
      defaultFormat="hex"
    >
      <div className="flex flex-col gap-4">
        <div className="flex items-center gap-3">
          <ColorPickerSwatch className="size-8" />
          <span className="font-medium text-sm">Selected Color</span>
        </div>
        <div className="flex flex-col gap-4 rounded-lg border p-4">
          <ColorPickerArea />
          <div className="flex items-center gap-2">
            <ColorPickerEyeDropper />
            <div className="flex flex-1 flex-col gap-2">
              <ColorPickerHueSlider />
              <ColorPickerAlphaSlider />
            </div>
          </div>
          <div className="flex items-center gap-2">
            <ColorPickerFormatSelect />
            <ColorPickerInput />
          </div>
        </div>
      </div>
    </ColorPicker>
  );
}

Controlled State

A color picker with controlled state management.

Preset Colors

Current color:#3b82f6
Picker state:Closed
"use client";
 
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
  ColorPicker,
  ColorPickerAlphaSlider,
  ColorPickerArea,
  ColorPickerContent,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHueSlider,
  ColorPickerInput,
  ColorPickerSwatch,
  ColorPickerTrigger,
} from "@/components/ui/color-picker";
 
const presetColors = [
  "#ef4444", // red
  "#f97316", // orange
  "#eab308", // yellow
  "#22c55e", // green
  "#3b82f6", // blue
  "#8b5cf6", // violet
  "#ec4899", // pink
  "#64748b", // gray
];
 
export function ColorPickerControlledDemo() {
  const [color, setColor] = React.useState("#3b82f6");
  const [isOpen, setIsOpen] = React.useState(false);
 
  const onReset = React.useCallback(() => {
    setColor("#000000");
    setIsOpen(false);
  }, []);
 
  const onPresetSelect = React.useCallback((presetColor: string) => {
    setColor(presetColor);
  }, []);
 
  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center gap-3">
        <ColorPicker
          value={color}
          onValueChange={setColor}
          open={isOpen}
          onOpenChange={setIsOpen}
          defaultFormat="hex"
        >
          <ColorPickerTrigger asChild>
            <Button variant="outline" className="flex items-center gap-2">
              <ColorPickerSwatch className="size-4" />
              Pick Color
            </Button>
          </ColorPickerTrigger>
          <ColorPickerContent>
            <ColorPickerArea />
            <div className="flex items-center gap-2">
              <ColorPickerEyeDropper />
              <div className="flex flex-1 flex-col gap-2">
                <ColorPickerHueSlider />
                <ColorPickerAlphaSlider />
              </div>
            </div>
            <div className="flex items-center gap-2">
              <ColorPickerFormatSelect />
              <ColorPickerInput />
            </div>
          </ColorPickerContent>
        </ColorPicker>
 
        <Button variant="outline" onClick={onReset}>
          Reset
        </Button>
      </div>
 
      <div className="flex flex-col gap-2">
        <h4 className="font-medium text-sm">Preset Colors</h4>
        <div className="flex flex-wrap gap-2">
          {presetColors.map((presetColor) => (
            <button
              key={presetColor}
              type="button"
              className="size-8 rounded border-2 border-transparent hover:border-border focus:border-ring focus:outline-none"
              style={{ backgroundColor: presetColor }}
              onClick={() => onPresetSelect(presetColor)}
              aria-label={`Select color ${presetColor}`}
            />
          ))}
        </div>
      </div>
 
      <div className="flex flex-col gap-2 text-sm">
        <div>
          <span className="font-medium">Current color:</span>
          <code className="ml-2 font-mono">{color}</code>
        </div>
        <div>
          <span className="font-medium">Picker state:</span>
          <span className="ml-2">{isOpen ? "Open" : "Closed"}</span>
        </div>
      </div>
    </div>
  );
}

With Form

A color picker with form integration.

Theme Colors

Configure your application's color scheme

"use client";
 
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import {
  ColorPicker,
  ColorPickerAlphaSlider,
  ColorPickerArea,
  ColorPickerContent,
  ColorPickerEyeDropper,
  ColorPickerFormatSelect,
  ColorPickerHueSlider,
  ColorPickerInput,
  ColorPickerSwatch,
  ColorPickerTrigger,
} from "@/components/ui/color-picker";
 
const formSchema = z.object({
  primaryColor: z
    .string()
    .min(1, "Primary color is required")
    .regex(/^#([0-9A-F]{3}){1,2}$/i, "Must be a valid hex color"),
  secondaryColor: z
    .string()
    .min(1, "Secondary color is required")
    .regex(/^#([0-9A-F]{3}){1,2}$/i, "Must be a valid hex color"),
  accentColor: z
    .string()
    .min(1, "Accent color is required")
    .regex(/^#([0-9A-F]{3}){1,2}$/i, "Must be a valid hex color"),
});
 
type FormValues = z.infer<typeof formSchema>;
 
export function ColorPickerFormDemo() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      primaryColor: "#3b82f6",
      secondaryColor: "#10b981",
      accentColor: "#f59e0b",
    },
  });
 
  const onSubmit = React.useCallback((input: FormValues) => {
    toast.success(
      <div className="space-y-2">
        <div className="font-medium">Theme Colors Updated!</div>
        <pre className="w-full text-xs">{JSON.stringify(input, null, 2)}</pre>
      </div>,
    );
  }, []);
 
  const onReset = React.useCallback(() => {
    form.reset();
  }, [form]);
 
  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex w-full max-w-md flex-col gap-4 rounded-md border p-6 shadow-sm"
      >
        <div className="flex flex-col gap-1">
          <h3 className="font-semibold text-lg">Theme Colors</h3>
          <p className="text-muted-foreground text-sm">
            Configure your application's color scheme
          </p>
        </div>
        <FormField
          control={form.control}
          name="primaryColor"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Primary Color</FormLabel>
              <FormControl>
                <ColorPicker
                  value={field.value}
                  onValueChange={field.onChange}
                  defaultFormat="hex"
                >
                  <div className="flex items-center gap-3">
                    <ColorPickerTrigger asChild>
                      <Button
                        variant="outline"
                        className="flex items-center gap-2 px-3"
                      >
                        <ColorPickerSwatch className="size-4" />
                        {field.value}
                      </Button>
                    </ColorPickerTrigger>
                  </div>
                  <ColorPickerContent>
                    <ColorPickerArea />
                    <div className="flex items-center gap-2">
                      <ColorPickerEyeDropper />
                      <div className="flex flex-1 flex-col gap-2">
                        <ColorPickerHueSlider />
                        <ColorPickerAlphaSlider />
                      </div>
                    </div>
                    <div className="flex items-center gap-2">
                      <ColorPickerFormatSelect />
                      <ColorPickerInput />
                    </div>
                  </ColorPickerContent>
                </ColorPicker>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="secondaryColor"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Secondary Color</FormLabel>
              <FormControl>
                <ColorPicker
                  value={field.value}
                  onValueChange={field.onChange}
                  defaultFormat="hex"
                >
                  <div className="flex items-center gap-3">
                    <ColorPickerTrigger asChild>
                      <Button
                        variant="outline"
                        className="flex items-center gap-2 px-3"
                      >
                        <ColorPickerSwatch className="size-4" />
                        {field.value}
                      </Button>
                    </ColorPickerTrigger>
                  </div>
                  <ColorPickerContent>
                    <ColorPickerArea />
                    <div className="flex items-center gap-2">
                      <ColorPickerEyeDropper />
                      <div className="flex flex-1 flex-col gap-2">
                        <ColorPickerHueSlider />
                        <ColorPickerAlphaSlider />
                      </div>
                    </div>
                    <div className="flex items-center gap-2">
                      <ColorPickerFormatSelect />
                      <ColorPickerInput />
                    </div>
                  </ColorPickerContent>
                </ColorPicker>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="accentColor"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Accent Color</FormLabel>
              <FormControl>
                <ColorPicker
                  value={field.value}
                  onValueChange={field.onChange}
                  defaultFormat="hex"
                >
                  <div className="flex items-center gap-3">
                    <ColorPickerTrigger asChild>
                      <Button
                        variant="outline"
                        className="flex items-center gap-2 px-3"
                      >
                        <ColorPickerSwatch className="size-4" />
                        {field.value}
                      </Button>
                    </ColorPickerTrigger>
                  </div>
                  <ColorPickerContent>
                    <ColorPickerArea />
                    <div className="flex items-center gap-2">
                      <ColorPickerEyeDropper />
                      <div className="flex flex-1 flex-col gap-2">
                        <ColorPickerHueSlider />
                        <ColorPickerAlphaSlider />
                      </div>
                    </div>
                    <div className="flex items-center gap-2">
                      <ColorPickerFormatSelect />
                      <ColorPickerInput />
                    </div>
                  </ColorPickerContent>
                </ColorPicker>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <div className="flex justify-end gap-2">
          <Button type="button" variant="outline" onClick={onReset}>
            Reset
          </Button>
          <Button type="submit">Save Theme</Button>
        </div>
      </form>
    </Form>
  );
}

API Reference

Root

The main container component for the color picker.

PropTypeDefault
asChild?
boolean
false
modal?
boolean
true
onOpenChange?
((open: boolean) => void)
-
open?
boolean
-
defaultOpen?
boolean
-
required?
boolean
false
readOnly?
boolean
false
inline?
boolean
false
disabled?
boolean
false
name?
string
-
onFormatChange?
((format: ColorFormat) => void)
-
defaultFormat?
ColorFormat
"hex"
format?
ColorFormat
-
dir?
Direction
"ltr"
onValueChange?
((value: string) => void)
-
defaultValue?
string
"#000000"
value?
string
-

Trigger

The trigger button that opens the color picker popover.

PropTypeDefault
asChild?
boolean
-

Content

The content container for the color picker components.

PropTypeDefault
forceMount?
true
-
onCloseAutoFocus?
((event: Event) => void)
-
onOpenAutoFocus?
((event: Event) => void)
-
onInteractOutside?
((event: PointerDownOutsideEvent | FocusOutsideEvent) => void)
-
onFocusOutside?
((event: FocusOutsideEvent) => void)
-
onPointerDownOutside?
((event: PointerDownOutsideEvent) => void)
-
onEscapeKeyDown?
((event: KeyboardEvent) => void)
-
updatePositionStrategy?
"always" | "optimized"
-
hideWhenDetached?
boolean
-
sticky?
"partial" | "always"
-
collisionPadding?
number | Partial<Record<"top" | "right" | "bottom" | "left", number>>
-
collisionBoundary?
Boundary | Boundary[]
-
avoidCollisions?
boolean
-
arrowPadding?
number
-
alignOffset?
number
-
align?
"center" | "start" | "end"
-
sideOffset?
number
-
side?
"top" | "right" | "bottom" | "left"
-
asChild?
boolean
-

Area

The 2D color area for selecting hue and saturation.

PropTypeDefault
asChild?
boolean
false

Swatch

A color swatch that displays the current color.

PropTypeDefault
asChild?
boolean
false

HueSlider

A slider for adjusting the hue value of the color.

PropTypeDefault
onValueCommit?
((value: number[]) => void)
-
onValueChange?
((value: number[]) => void)
-
value?
number[]
-
minStepsBetweenThumbs?
number
-
step?
number
-
orientation?
"horizontal" | "vertical"
-
disabled?
boolean
-
name?
string
-
asChild?
boolean
-
inverted?
boolean
-
max?
number
-
min?
number
-
form?
string
-

AlphaSlider

A slider for adjusting the alpha (transparency) value of the color.

PropTypeDefault
onValueCommit?
((value: number[]) => void)
-
onValueChange?
((value: number[]) => void)
-
value?
number[]
-
minStepsBetweenThumbs?
number
-
step?
number
-
orientation?
"horizontal" | "vertical"
-
disabled?
boolean
-
name?
string
-
asChild?
boolean
-
inverted?
boolean
-
max?
number
-
min?
number
-
form?
string
-

EyeDropper

A button that activates the browser's native eye dropper tool to pick colors from the screen.

PropTypeDefault
asChild?
boolean
-
size?
"default" | "sm" | "lg" | "icon" | null
"sm"
variant?
"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null
"default"
Data AttributeValue
[data-disabled]Present when the component is disabled.

Note: The EyeDropper component only renders when the browser supports the native EyeDropper API. It will return null in unsupported browsers.

FormatSelect

A select dropdown for choosing the color format (hex, rgb, hsl, hsb).

PropTypeDefault
asChild?
boolean
false
className?
string
-
defaultValue?
string
-
form?
string
-
required?
boolean
-
disabled?
boolean
-
autoComplete?
string
-
name?
string
-
dir?
Direction
-
onOpenChange?
((open: boolean) => void)
-
defaultOpen?
boolean
-
open?
boolean
-
children?
ReactNode
-
size?
"sm" | "default"
"sm"

Input

An input field that displays and allows editing of the color value in the selected format.

PropTypeDefault
asChild?
boolean
false
withoutAlpha?
boolean
false

Accessibility

Keyboard Interactions

KeyDescription
TabMoves focus to the next focusable element.
Shift + TabMoves focus to the previous focusable element.
EnterSpaceOpens the color picker when focus is on the trigger.
EscapeCloses the color picker and moves focus to the trigger.
ArrowUpArrowDownArrowLeftArrowRightAdjusts the color value when focus is on sliders or the color area.

ARIA Labels

  • The color swatch includes an aria-label that describes the current color value
  • The color area and sliders have appropriate aria-label attributes for screen readers
  • The eye dropper button includes descriptive text for its functionality

Browser Support

EyeDropper API

The EyeDropper component requires browser support for the native EyeDropper API:

  • Chrome/Edge: Supported from version 95+
  • Firefox: Not supported
  • Safari: Not supported

The component gracefully handles unsupported browsers by not rendering the eye dropper button.

Color Formats

The color picker supports the following color formats:

  • HEX: Hexadecimal color notation (e.g., #3b82f6)
  • RGB: Red, Green, Blue color notation (e.g., rgb(59, 130, 246))
  • HSL: Hue, Saturation, Lightness color notation (e.g., hsl(217, 91%, 60%))
  • HSB: Hue, Saturation, Brightness color notation (e.g., hsb(217, 76%, 96%))

All formats support alpha channel for transparency when not using the withoutAlpha prop.