Color Picker
Color picker on react-colorful with HEX / RGB / HSL inputs (colord), an eyedropper where supported, and recent swatches.
Example
Installation
npx shadcn@latest add https://hirael.com/r/color-picker.jsonUsage
import {
ColorPicker,
ColorPickerTrigger,
ColorPickerContent,
ColorPickerArea,
ColorPickerFormatTabs,
ColorPickerFormatInputs,
ColorPickerEyedropper,
ColorPickerSwatches,
} from "@/components/color-picker"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<ColorPicker />
| Prop | Type | Default |
|---|---|---|
value | string | none |
defaultValue | string | '#0ea5e9' |
onValueChange | ((hex: string) => void) | none |
format | ColorFormat | none |
defaultFormat | ColorFormat | 'hex' |
onFormatChange | ((format: ColorFormat) => void) | none |
swatches | string[] | none |
recentLimit | number | 8 |
disabled | boolean | none |
open | boolean | none |
defaultOpen | boolean | false |
onOpenChange | ((open: boolean) => void) | none |
children | React.ReactNode | none |
<ColorPickerTrigger />
+ native element props| Prop | Type | Default |
|---|---|---|
placeholder | string | 'Pick a color' |
children | React.ReactNode | none |
<ColorPickerContent />
+ native element propsNo props of its own; forwards everything to the underlying element.
<ColorPickerArea />
+ native element propsNo props of its own; forwards everything to the underlying element.
<ColorPickerFormatTabs />
+ native element propsNo props of its own; forwards everything to the underlying element.
<ColorPickerFormatInputs />
+ native element props| Prop | Type | Default |
|---|---|---|
hexLabelAccessible name of the hex field. | string | 'Hex color' |
channelLabelAccessible name of an RGB or HSL channel field. | ((channel: string) => string) | defaultChannelLabel |
<ColorPickerEyedropper />
+ native element propsNo props of its own; forwards everything to the underlying element.
<ColorPickerSwatches />
+ native element props| Prop | Type | Default |
|---|---|---|
swatchLabelAccessible name of a swatch button. | ((color: string) => string) | defaultSwatchLabel |
Component source
'use client';
import * as React from 'react';
import { colord } from 'colord';
import { Pipette } from 'lucide-react';
import { HexColorPicker } from 'react-colorful';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { Tabs, TabsList, TabsTrigger } from '@/registry/hirael/bases/radix/ui/tabs';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
export type ColorFormat = 'hex' | 'rgb' | 'hsl';
interface ColorPickerContextValue {
hex: string;
setHex: (hex: string) => void;
format: ColorFormat;
setFormat: (f: ColorFormat) => void;
swatches: string[];
pushSwatch: (hex: string) => void;
disabled?: boolean;
}
const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(null);
const useColorPicker = () => {
const ctx = React.useContext(ColorPickerContext);
if (!ctx) {
throw new Error('ColorPicker compound components must be used inside <ColorPicker>');
}
return ctx;
};
const DEFAULT_SWATCHES = ['#0ea5e9', '#22c55e', '#a855f7', '#f43f5e', '#f97316', '#facc15', '#14b8a6', '#64748b'];
export interface ColorPickerProps {
value?: string;
defaultValue?: string;
onValueChange?: (hex: string) => void;
format?: ColorFormat;
defaultFormat?: ColorFormat;
onFormatChange?: (format: ColorFormat) => void;
swatches?: string[];
recentLimit?: number;
disabled?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
const ColorPicker = ({
value: valueProp,
defaultValue = '#0ea5e9',
onValueChange,
format: formatProp,
defaultFormat = 'hex',
onFormatChange,
swatches: swatchesProp,
recentLimit = 8,
disabled,
open: openProp,
defaultOpen = false,
onOpenChange,
children,
}: ColorPickerProps) => {
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: onValueChange,
});
const [format, setFormat] = useControllableState<ColorFormat>({
prop: formatProp,
defaultProp: defaultFormat,
onChange: onFormatChange,
});
const [recent, setRecent] = React.useState<string[]>([]);
const pushSwatch = React.useCallback(
(hex: string) =>
setRecent((prev) => [hex, ...prev.filter((c) => c.toLowerCase() !== hex.toLowerCase())].slice(0, recentLimit)),
[recentLimit],
);
const [open, setControlledOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const setOpen = (next: boolean) => {
setControlledOpen(next);
// Remember the colour the popover closes on, not every step of a drag.
if (!next) pushSwatch(value.toLowerCase());
};
const swatches = React.useMemo(() => {
const merged = swatchesProp ? [...swatchesProp, ...recent] : [...recent, ...DEFAULT_SWATCHES];
return merged.filter((c, i) => merged.findIndex((o) => o.toLowerCase() === c.toLowerCase()) === i).slice(0, 16);
}, [swatchesProp, recent]);
const ctx = React.useMemo<ColorPickerContextValue>(
() => ({ hex: value, setHex: setValue, format, setFormat, swatches, pushSwatch, disabled }),
[value, setValue, format, setFormat, swatches, pushSwatch, disabled],
);
return (
<ColorPickerContext.Provider value={ctx}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</ColorPickerContext.Provider>
);
};
interface ColorPickerTriggerProps extends Omit<React.ComponentProps<'button'>, 'children'> {
placeholder?: string;
children?: React.ReactNode;
}
const ColorPickerTrigger = ({
placeholder = 'Pick a color',
className,
children,
...props
}: ColorPickerTriggerProps) => {
const ctx = useColorPicker();
return (
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={ctx.disabled}
data-slot="color-picker-trigger"
className={cn('w-full justify-between font-normal uppercase tabular-nums', className)}
{...props}
>
{children ?? (
<span className="flex items-center gap-2">
<span
aria-hidden
className="inline-block size-5 shrink-0 rounded-sm border border-border"
style={{ backgroundColor: ctx.hex }}
/>
<span>{ctx.hex || placeholder}</span>
</span>
)}
</Button>
</PopoverTrigger>
);
};
/** Saturation, brightness and hue in one control. */
const ColorPickerArea = ({ className, ...props }: React.ComponentProps<'div'>) => {
const ctx = useColorPicker();
return (
<div
data-slot="color-picker-area"
className={cn('[&_.react-colorful]:w-full', ctx.disabled && 'pointer-events-none opacity-50', className)}
{...props}
>
<HexColorPicker color={ctx.hex} onChange={ctx.setHex} />
</div>
);
};
const ColorPickerFormatTabs = ({
className,
...props
}: Omit<React.ComponentProps<typeof Tabs>, 'value' | 'onValueChange'>) => {
const ctx = useColorPicker();
return (
<Tabs
{...props}
data-slot="color-picker-format-tabs"
value={ctx.format}
onValueChange={(v) => ctx.setFormat(v as ColorFormat)}
className={className}
>
<TabsList className="w-full">
{(['hex', 'rgb', 'hsl'] as const).map((f) => (
<TabsTrigger key={f} value={f} className="text-xs uppercase">
{f}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
};
interface ChannelInputProps {
value: number;
max: number;
onCommit: (n: number) => void;
'aria-label': string;
}
// Keeps the raw text while editing, so clearing a field doesn't snap it to 0; out-of-range input clamps on blur or Enter.
const ChannelInput = ({ value, max, onCommit, 'aria-label': ariaLabel }: ChannelInputProps) => {
const [draft, setDraft] = React.useState<string | null>(null);
const parse = (raw: string) => (/^\d+$/.test(raw.trim()) ? Number(raw) : null);
const commit = () => {
const n = draft === null ? null : parse(draft);
if (n !== null) onCommit(Math.min(max, n));
setDraft(null);
};
return (
<Input
value={draft ?? String(Math.round(value))}
onChange={(e) => {
setDraft(e.target.value);
const n = parse(e.target.value);
if (n !== null && n <= max) onCommit(n);
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
inputMode="numeric"
aria-label={ariaLabel}
className="h-8 px-2 text-center text-xs tabular-nums"
/>
);
};
interface ColorPickerFormatInputsProps extends React.ComponentProps<'div'> {
/** Accessible name of the hex field. */
hexLabel?: string;
/** Accessible name of an RGB or HSL channel field. */
channelLabel?: (channel: string) => string;
}
const defaultChannelLabel = (channel: string) => `${channel.toUpperCase()} channel`;
const ColorPickerFormatInputs = ({
hexLabel = 'Hex color',
channelLabel = defaultChannelLabel,
className,
...props
}: ColorPickerFormatInputsProps) => {
const ctx = useColorPicker();
const color = colord(ctx.hex);
const [hexDraft, setHexDraft] = React.useState<string | null>(null);
if (ctx.format === 'hex') {
const commitHex = () => {
const next = hexDraft && colord(hexDraft.startsWith('#') ? hexDraft : `#${hexDraft}`);
if (next && next.isValid()) {
ctx.setHex(next.toHex());
ctx.pushSwatch(next.toHex());
}
setHexDraft(null);
};
return (
<div {...props} data-slot="color-picker-format-inputs" className={className}>
<Input
value={hexDraft ?? ctx.hex.toUpperCase()}
onChange={(e) => {
setHexDraft(e.target.value.toUpperCase());
const next = colord(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`);
if (/^#?[0-9a-f]{6}$/i.test(e.target.value.trim()) && next.isValid()) ctx.setHex(next.toHex());
}}
onBlur={commitHex}
onKeyDown={(e) => {
if (e.key === 'Enter') commitHex();
}}
aria-label={hexLabel}
className="h-8 text-xs uppercase tabular-nums"
/>
</div>
);
}
const channels =
ctx.format === 'rgb'
? (['r', 'g', 'b'] as const).map((k) => ({
k,
value: color.toRgb()[k],
max: 255,
commit: (n: number) => ctx.setHex(colord({ ...color.toRgb(), [k]: n }).toHex()),
}))
: (['h', 's', 'l'] as const).map((k) => ({
k,
value: color.toHsl()[k],
max: k === 'h' ? 360 : 100,
commit: (n: number) => ctx.setHex(colord({ ...color.toHsl(), [k]: n }).toHex()),
}));
return (
<div {...props} data-slot="color-picker-format-inputs" className={cn('grid grid-cols-3 gap-1.5', className)}>
{channels.map((c) => (
<ChannelInput key={c.k} value={c.value} max={c.max} onCommit={c.commit} aria-label={channelLabel(c.k)} />
))}
</div>
);
};
interface EyeDropperResult {
sRGBHex: string;
}
type EyeDropperCtor = new () => { open: () => Promise<EyeDropperResult> };
const ColorPickerEyedropper = ({
'aria-label': ariaLabel = 'Pick color from screen',
className,
...props
}: Omit<React.ComponentProps<'button'>, 'onClick' | 'children'>) => {
const ctx = useColorPicker();
const supported = React.useSyncExternalStore(
() => () => {},
() => 'EyeDropper' in window,
() => false,
);
if (!supported) return null;
return (
<Button
{...props}
type="button"
variant="outline"
size="icon-sm"
data-slot="color-picker-eyedropper"
aria-label={ariaLabel}
onClick={async () => {
try {
const Ctor = (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper;
const dropper = new Ctor();
const res = await dropper.open();
const next = res.sRGBHex.toLowerCase();
ctx.setHex(next);
ctx.pushSwatch(next);
} catch {}
}}
className={cn('shrink-0', className)}
>
<Pipette className="size-3.5" />
</Button>
);
};
interface ColorPickerSwatchesProps extends React.ComponentProps<'div'> {
/** Accessible name of a swatch button. */
swatchLabel?: (color: string) => string;
}
const defaultSwatchLabel = (color: string) => `Use ${color}`;
const ColorPickerSwatches = ({ swatchLabel = defaultSwatchLabel, className, ...props }: ColorPickerSwatchesProps) => {
const ctx = useColorPicker();
if (ctx.swatches.length === 0) return null;
return (
<div {...props} data-slot="color-picker-swatches" className={cn('flex flex-wrap gap-1', className)}>
{ctx.swatches.map((s) => {
const active = s.toLowerCase() === ctx.hex.toLowerCase();
return (
<button
key={s}
type="button"
data-slot="color-picker-swatch"
aria-label={swatchLabel(s)}
aria-pressed={active}
onClick={() => {
ctx.setHex(s.toLowerCase());
ctx.pushSwatch(s.toLowerCase());
}}
className={cn(
'size-5 rounded-sm border transition-transform hover:scale-110',
active ? 'border-foreground ring-1 ring-foreground' : 'border-border',
)}
style={{ backgroundColor: s }}
/>
);
})}
</div>
);
};
/** Renders the default layout; pass children to compose your own from the parts. */
const ColorPickerContent = ({ className, children, ...props }: React.ComponentProps<typeof PopoverContent>) => {
const ctx = useColorPicker();
return (
<PopoverContent align="start" data-slot="color-picker-content" className={cn('w-64 p-3', className)} {...props}>
{children ?? (
<div className="flex flex-col gap-3">
<ColorPickerArea />
<div className="flex items-center gap-2">
<span
aria-hidden
data-slot="color-picker-preview"
className="inline-block h-8 flex-1 rounded-sm border border-border"
style={{ backgroundColor: ctx.hex }}
/>
<ColorPickerEyedropper />
</div>
<ColorPickerFormatTabs />
<ColorPickerFormatInputs />
<ColorPickerSwatches />
</div>
)}
</PopoverContent>
);
};
export {
ColorPicker,
ColorPickerTrigger,
ColorPickerContent,
ColorPickerArea,
ColorPickerFormatTabs,
ColorPickerFormatInputs,
ColorPickerEyedropper,
ColorPickerSwatches,
};
Dependencies
shadcn registry
buttoninputpopovertabsuse-controllable-state
npm
colordlucide-reactreact-colorful