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.
"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
#3b82f6
"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.
"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.
Prop | Type | Default |
---|---|---|
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.
Prop | Type | Default |
---|---|---|
asChild? | boolean | - |
Content
The content container for the color picker components.
Prop | Type | Default |
---|---|---|
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.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
Swatch
A color swatch that displays the current color.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
HueSlider
A slider for adjusting the hue value of the color.
Prop | Type | Default |
---|---|---|
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.
Prop | Type | Default |
---|---|---|
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.
Prop | Type | Default |
---|---|---|
asChild? | boolean | - |
size? | "default" | "sm" | "lg" | "icon" | null | "sm" |
variant? | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | "default" |
Data Attribute | Value |
---|---|
[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).
Prop | Type | Default |
---|---|---|
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.
Prop | Type | Default |
---|---|---|
asChild? | boolean | false |
withoutAlpha? | boolean | false |
Accessibility
Keyboard Interactions
Key | Description |
---|---|
Tab | Moves focus to the next focusable element. |
Shift + Tab | Moves focus to the previous focusable element. |
EnterSpace | Opens the color picker when focus is on the trigger. |
Escape | Closes the color picker and moves focus to the trigger. |
ArrowUpArrowDownArrowLeftArrowRight | Adjusts 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.