Color Picker
A composable color picker component with modular parts for building custom color selection interfaces.
'use client';
import { useState } from 'react';
import {
ColorPicker,
ColorPickerArea,
ColorPickerHue,
type HSVA,
} from '@/components/color-picker';
export default function ColorPickerExample() {
const [color, setColor] = useState<HSVA>([0, 1, 1, 1]);
return (
<ColorPicker className="w-48" color={color} onColorChange={setColor}>
<ColorPickerArea />
<ColorPickerHue className="mt-2" />
</ColorPicker>
);
} Dependencies
Source Code
'use client';
import chroma from 'chroma-js';
import { createContext, use, useMemo, useRef } from 'react';
import { composeRefs } from '@/lib/compose-refs';
import { cn } from '@/lib/utils/classnames';
type HSVA = [number, number, number, number];
interface ColorPickerContextType {
color: HSVA;
onChange?: (color: HSVA) => void;
disabled?: boolean;
}
const ColorPickerContext = createContext<ColorPickerContextType | null>(null);
const useColorPickerContext = () => {
const context = use(ColorPickerContext);
if (!context) {
throw new Error('ColorPicker components must be used within a ColorPicker');
}
return context;
};
interface ColorPickerProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'color' | 'onChange'> {
color?: HSVA;
onColorChange?: (color: HSVA) => void;
disabled?: boolean;
}
const HANDLE_SIZE_RADIUS = 10;
const ColorPicker = ({
children,
color = [0, 1, 1, 1],
onColorChange,
disabled,
className,
ref,
...props
}: ColorPickerProps) => {
const contextValue = useMemo(
() => ({
color,
onChange: onColorChange,
disabled,
}),
[color, onColorChange, disabled]
);
return (
<ColorPickerContext value={contextValue}>
<div
ref={ref}
className={cn(disabled && 'pointer-events-none opacity-48', className)}
{...props}
>
{children}
</div>
</ColorPickerContext>
);
};
interface DraggableProps extends React.ComponentPropsWithRef<'div'> {
onMove: (coords: [number, number]) => void;
keyMap?: Partial<
Record<'up' | 'down' | 'left' | 'right', (modifierKey: boolean) => void>
>;
disabled?: boolean;
'aria-label'?: string;
'aria-valuetext'?: string;
'aria-valuenow'?: number;
'aria-valuemin'?: number;
'aria-valuemax'?: number;
}
const Draggable = ({
disabled,
className,
onMove,
keyMap = {},
children,
'aria-label': ariaLabel,
'aria-valuetext': ariaValueText,
'aria-valuenow': ariaValueNow,
'aria-valuemin': ariaValueMin = 0,
'aria-valuemax': ariaValueMax = 100,
ref,
...props
}: DraggableProps) => {
const internalRef = useRef<HTMLDivElement>(null);
const isSelecting = useRef(false);
const handleStart = (
e:
| MouseEvent
| TouchEvent
| React.MouseEvent<HTMLDivElement>
| React.TouchEvent<HTMLDivElement>
) => {
if (disabled || !internalRef.current) return;
const el = internalRef.current;
if (!el.contains(e.target as Node)) return;
if (e.cancelable) e.preventDefault();
isSelecting.current = true;
window.addEventListener('mousemove', handleMove, { passive: false });
window.addEventListener('touchmove', handleMove, { passive: false });
window.addEventListener('mouseup', handleStop);
window.addEventListener('touchend', handleStop);
onMove(getCoordinatesFromEvent(e));
};
const handleStop = () => {
isSelecting.current = false;
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('mouseup', handleStop);
window.removeEventListener('touchend', handleStop);
};
const handleMove = (e: MouseEvent | TouchEvent) => {
if (!isSelecting.current) return;
if (e.cancelable) e.preventDefault();
onMove(getCoordinatesFromEvent(e));
};
const getCoordinatesFromEvent = (
e:
| MouseEvent
| TouchEvent
| React.MouseEvent<HTMLDivElement>
| React.TouchEvent<HTMLDivElement>
) => {
const rect = internalRef.current?.getBoundingClientRect();
if (!rect) return [0, 0] as [number, number];
const clientX = 'touches' in e ? (e.touches[0]?.clientX ?? 0) : e.clientX;
const clientY = 'touches' in e ? (e.touches[0]?.clientY ?? 0) : e.clientY;
const x = clientX - rect.left;
const y = clientY - rect.top;
return [
Math.min(Math.max(x, 0), rect.width),
Math.min(Math.max(y, 0), rect.height),
] as [number, number];
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const key = e.key.toLowerCase();
const modifierKey = e.shiftKey || e.metaKey;
if (key === 'arrowup' && keyMap?.up) {
e.preventDefault();
keyMap.up(modifierKey);
} else if (key === 'arrowdown' && keyMap?.down) {
e.preventDefault();
keyMap.down(modifierKey);
} else if (key === 'arrowleft' && keyMap?.left) {
e.preventDefault();
keyMap.left(modifierKey);
} else if (key === 'arrowright' && keyMap?.right) {
e.preventDefault();
keyMap.right(modifierKey);
}
};
return (
<div
ref={composeRefs(internalRef, ref)}
onMouseDown={handleStart}
onTouchStart={handleStart}
onKeyDown={handleKeyDown}
className={cn(
'group relative cursor-pointer touch-none rounded-xl',
'focus-visible:ring-4 focus-visible:ring-accent-element',
className
)}
tabIndex={disabled ? -1 : 0}
role="slider"
aria-label={ariaLabel}
aria-valuetext={ariaValueText}
aria-valuenow={ariaValueNow}
aria-valuemin={ariaValueMin}
aria-valuemax={ariaValueMax}
aria-disabled={disabled}
{...props}
>
{children}
</div>
);
};
interface ColorPickerHandleProps {
color: HSVA;
left: number;
top?: number;
}
const ColorPickerHandle = ({
color,
left,
top = 0.5,
}: ColorPickerHandleProps) => {
return (
<div
className={cn(
'absolute h-[var(--size)] w-[var(--size)] -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white bg-[var(--color)] shadow-lg',
'transition-transform group-focus-visible:scale-110 group-focus-visible:outline-4 group-focus-visible:outline-[white]/40'
)}
style={{
'--size': `${HANDLE_SIZE_RADIUS * 2}px`,
'--color': chroma.hsv(color[0], color[1], color[2]).css(),
left: `${left * 100}%`,
top: `${top * 100}%`,
}}
/>
);
};
const ColorPickerArea = ({
className,
ref,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const {
color: [h, s, v, a],
onChange,
disabled,
} = useColorPickerContext();
const internalRef = useRef<HTMLDivElement>(null);
const onMove = ([x, y]: [number, number]) => {
const rect = internalRef.current?.getBoundingClientRect();
if (!rect) return;
const normalizedX = x / rect.width;
const normalizedY = 1 - y / rect.height;
onChange?.([h, normalizedX, normalizedY, a]);
};
const keyMap = {
up: (modifierKey: boolean) =>
onChange?.([h, s, Math.min(1, v + (modifierKey ? 0.1 : 0.02)), a]),
down: (modifierKey: boolean) =>
onChange?.([h, s, Math.max(0, v - (modifierKey ? 0.1 : 0.02)), a]),
left: (modifierKey: boolean) =>
onChange?.([h, Math.max(0, s - (modifierKey ? 0.1 : 0.02)), v, a]),
right: (modifierKey: boolean) =>
onChange?.([h, Math.min(1, s + (modifierKey ? 0.1 : 0.02)), v, a]),
};
const ariaValueText = `Saturation ${Math.round(s * 100)}%, Value ${Math.round(v * 100)}%`;
return (
<Draggable
ref={composeRefs(internalRef, ref)}
onMove={onMove}
keyMap={keyMap}
disabled={disabled}
aria-label="Saturation and value"
aria-valuetext={ariaValueText}
aria-valuenow={Math.round(((s + v) / 2) * 100)}
aria-valuemin={0}
aria-valuemax={100}
className={cn('aspect-square w-full', className)}
style={{
backgroundColor: chroma(h, 1, 1, 'hsv').css(),
backgroundImage: `linear-gradient(transparent, black),
linear-gradient(to right, white, transparent)`,
}}
{...props}
>
<ColorPickerHandle color={[h, s, v, a]} top={1 - v} left={s} />
</Draggable>
);
};
const ColorPickerHue = ({
className,
ref,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const {
color: [h, s, v, a],
onChange,
disabled,
} = useColorPickerContext();
const internalRef = useRef<HTMLDivElement>(null);
const onMove = ([x]: [number, number]) => {
const rect = internalRef.current?.getBoundingClientRect();
if (!rect) return;
const normalizedX = x / rect.width;
const newH = normalizedX * 360;
onChange?.([newH, s, v, a]);
};
const keyMap = {
left: (modifierKey: boolean) => {
const newH = Math.max(0, h - (modifierKey ? 10 : 2));
onChange?.([newH, s, v, a]);
},
right: (modifierKey: boolean) => {
const newH = Math.min(360, h + (modifierKey ? 10 : 2));
onChange?.([newH, s, v, a]);
},
};
const ariaValueText = `Hue ${Math.round(h)}°`;
return (
<Draggable
ref={composeRefs(internalRef, ref)}
onMove={onMove}
keyMap={keyMap}
disabled={disabled}
aria-label="Hue"
aria-valuetext={ariaValueText}
aria-valuenow={Math.round(h)}
aria-valuemin={0}
aria-valuemax={360}
className={cn('h-5 w-full', className)}
style={{
background:
'linear-gradient(90deg, hsl(0, 100%, 50%), hsl(30, 100%, 50%), hsl(60, 100%, 50%), hsl(90, 100%, 50%), hsl(120, 100%, 50%), hsl(150, 100%, 50%), hsl(180, 100%, 50%), hsl(210, 100%, 50%), hsl(240, 100%, 50%), hsl(270, 100%, 50%),hsl(300, 100%, 50%), hsl(330, 100%, 50%), hsl(360, 100%, 50%))',
}}
{...props}
>
<ColorPickerHandle color={[h, 1, 1, 1]} left={h / 360} />
</Draggable>
);
};
const ColorPickerSaturation = ({
className,
ref,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const {
color: [h, s, v, a],
onChange,
disabled,
} = useColorPickerContext();
const internalRef = useRef<HTMLDivElement>(null);
const onMove = ([x]: [number, number]) => {
const rect = internalRef.current?.getBoundingClientRect();
if (!rect) return;
const normalizedX = x / rect.width;
onChange?.([h, normalizedX, v, a]);
};
const keyMap = {
left: (modifierKey: boolean) => {
const newS = Math.max(0, s - (modifierKey ? 0.1 : 0.02));
onChange?.([h, newS, v, a]);
},
right: (modifierKey: boolean) => {
const newS = Math.min(1, s + (modifierKey ? 0.1 : 0.02));
onChange?.([h, newS, v, a]);
},
};
const ariaValueText = `Saturation ${Math.round(s * 100)}%`;
return (
<Draggable
ref={composeRefs(internalRef, ref)}
onMove={onMove}
keyMap={keyMap}
disabled={disabled}
aria-label="Saturation"
aria-valuetext={ariaValueText}
aria-valuenow={Math.round(s * 100)}
aria-valuemin={0}
aria-valuemax={100}
className={cn('h-5 w-full', className)}
style={{
background: `linear-gradient(90deg, ${chroma.hsv(h, 0, v).css()}, ${chroma.hsv(h, 1, v).css()})`,
}}
{...props}
>
<ColorPickerHandle color={[h, s, v, a]} left={s} />
</Draggable>
);
};
const ColorPickerLightness = ({
className,
ref,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const {
color: [h, s, v, a],
onChange,
disabled,
} = useColorPickerContext();
const internalRef = useRef<HTMLDivElement>(null);
const onMove = ([x]: [number, number]) => {
const rect = internalRef.current?.getBoundingClientRect();
if (!rect) return;
const normalizedX = x / rect.width;
onChange?.([h, s, normalizedX, a]);
};
const keyMap = {
left: (modifierKey: boolean) => {
const newV = Math.max(0, v - (modifierKey ? 0.1 : 0.02));
onChange?.([h, s, newV, a]);
},
right: (modifierKey: boolean) => {
const newV = Math.min(1, v + (modifierKey ? 0.1 : 0.02));
onChange?.([h, s, newV, a]);
},
};
const ariaValueText = `Lightness ${Math.round(v * 100)}%`;
return (
<Draggable
ref={composeRefs(internalRef, ref)}
onMove={onMove}
keyMap={keyMap}
disabled={disabled}
aria-label="Lightness"
aria-valuetext={ariaValueText}
aria-valuenow={Math.round(v * 100)}
aria-valuemin={0}
aria-valuemax={100}
className={cn('h-5 w-full', className)}
style={{
background: `linear-gradient(90deg, ${chroma.hsv(h, s, 0).css()}, ${chroma.hsv(h, s, 1).css()})`,
}}
{...props}
>
<ColorPickerHandle color={[h, s, v, a]} left={v} />
</Draggable>
);
};
const ColorPickerAlpha = ({
className,
ref,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const {
color: [h, s, v, a],
onChange,
disabled,
} = useColorPickerContext();
const internalRef = useRef<HTMLDivElement>(null);
const onMove = ([x]: [number, number]) => {
const rect = internalRef.current?.getBoundingClientRect();
if (!rect) return;
const normalizedX = x / rect.width;
onChange?.([h, s, v, normalizedX]);
};
const keyMap = {
left: (modifierKey: boolean) => {
const newAlpha = Math.max(0, a - (modifierKey ? 0.1 : 0.02));
onChange?.([h, s, v, newAlpha]);
},
right: (modifierKey: boolean) => {
const newAlpha = Math.min(1, a + (modifierKey ? 0.1 : 0.02));
onChange?.([h, s, v, newAlpha]);
},
};
const ariaValueText = `Alpha ${Math.round(a * 100)}%`;
const baseColor = chroma.hsv(h, s, v).css();
return (
<Draggable
ref={composeRefs(internalRef, ref)}
onMove={onMove}
keyMap={keyMap}
disabled={disabled}
aria-label="Alpha"
aria-valuetext={ariaValueText}
aria-valuenow={Math.round(a * 100)}
aria-valuemin={0}
aria-valuemax={100}
className={cn('relative h-5 w-full', className)}
style={{
backgroundImage: `
linear-gradient(45deg, rgba(0,0,0,0.05) 25%, transparent 25%),
linear-gradient(-45deg, rgba(0,0,0,0.05) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.05) 75%),
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.05) 75%),
linear-gradient(90deg, transparent, ${baseColor})
`,
backgroundSize: '8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%',
backgroundPosition: '0 0, 0 4px, 4px -4px, -4px 0px, 0 0',
}}
{...props}
>
<ColorPickerHandle color={[h, s, v, a]} left={a} />
</Draggable>
);
};
export {
ColorPicker,
ColorPickerAlpha,
ColorPickerArea,
ColorPickerHue,
ColorPickerLightness,
ColorPickerSaturation,
type HSVA,
}; Features
- Composable Architecture: Mix and match different color picker parts
- Real-time Updates: Immediate color feedback as you interact with the picker
- Keyboard Navigation: Full keyboard support with arrow keys for precise adjustments
- Accessibility: ARIA-compliant with proper screen reader support
- Touch Support: Works seamlessly on touch devices
Anatomy
<ColorPicker>
<ColorPickerArea />
<ColorPickerHue />
<ColorPickerSaturation />
<ColorPickerLightness />
<ColorPickerAlpha />
</ColorPicker>
API Reference
ColorPicker
The root component that provides context for all color picker parts.
| Prop | Default | Type | Description |
|---|---|---|---|
color | [0, 1, 1, 1] | HSVA | Current color value in HSVA format [hue, saturation, value, alpha]. Hue is 0-360, others are 0-1. |
onColorChange | - | (color: HSVA) => void | Callback function that receives the new color value when the user makes a selection. |
disabled | false | boolean | When true, prevents user interaction with all color picker parts. |
ColorPickerArea
A 2D saturation-value picker that displays as a square gradient.
ColorPickerHue
A horizontal hue slider that displays the full color spectrum.
ColorPickerSaturation
A horizontal saturation slider for the current hue.
ColorPickerLightness
A horizontal lightness/value slider for the current hue and saturation.
ColorPickerAlpha
A horizontal alpha/opacity slider with a checkered background pattern.
Examples
Basic Usage
'use client';
import { useState } from 'react';
import {
ColorPicker,
ColorPickerArea,
ColorPickerHue,
type HSVA,
} from '@/components/color-picker';
export default function ColorPickerExample() {
const [color, setColor] = useState<HSVA>([0, 1, 1, 1]);
return (
<ColorPicker className="w-48" color={color} onColorChange={setColor}>
<ColorPickerArea />
<ColorPickerHue className="mt-2" />
</ColorPicker>
);
} With Initial Color
'use client';
import { useState } from 'react';
import {
ColorPicker,
ColorPickerArea,
ColorPickerHue,
type HSVA,
} from '@/components/color-picker';
export default function ColorPickerWithInitialColor() {
const [color, setColor] = useState<HSVA>([210, 0.8, 0.9, 1]);
return (
<ColorPicker className="w-48" color={color} onColorChange={setColor}>
<ColorPickerArea />
<ColorPickerHue className="mt-2" />
</ColorPicker>
);
} Disabled State
import {
ColorPicker,
ColorPickerArea,
ColorPickerHue,
type HSVA,
} from '@/components/color-picker';
export default function ColorPickerDisabled() {
const color: HSVA = [120, 0.7, 0.8, 1];
return (
<ColorPicker color={color} disabled>
<ColorPickerArea className="size-48" />
<ColorPickerHue className="mt-2 w-48" />
</ColorPicker>
);
} All Controls
'use client';
import { useState } from 'react';
import {
ColorPicker,
ColorPickerAlpha,
ColorPickerArea,
ColorPickerHue,
ColorPickerLightness,
ColorPickerSaturation,
type HSVA,
} from '@/components/color-picker';
export default function ColorPickerAllControls() {
const [color, setColor] = useState<HSVA>([280, 0.6, 0.9, 0.8]);
return (
<ColorPicker
className="flex w-40 flex-col gap-2"
color={color}
onColorChange={setColor}
>
<ColorPickerArea className="size-40" />
<ColorPickerHue />
<ColorPickerSaturation />
<ColorPickerLightness />
<ColorPickerAlpha />
</ColorPicker>
);
} Multiple formats
HEX
#FF0000RGBrgb(255 0 0)HSV0, 100%, 100%'use client';
import chroma from 'chroma-js';
import { useState } from 'react';
import {
ColorPicker,
ColorPickerAlpha,
ColorPickerArea,
ColorPickerHue,
ColorPickerSaturation,
type HSVA,
} from '@/components/color-picker';
export default function ColorPickerMultipleFormatsExample() {
const [color, setColor] = useState<HSVA>([0, 1, 1, 1]);
return (
<div className="flex justify-center">
<div className="flex gap-4">
<div className="flex flex-col gap-4">
<ColorPicker className="w-36" color={color} onColorChange={setColor}>
<ColorPickerArea />
<ColorPickerHue className="mt-2" />
<ColorPickerSaturation className="mt-2" />
<ColorPickerAlpha className="mt-2" />
</ColorPicker>
</div>
<div className="flex w-36 flex-col gap-3">
<div
className="h-8 w-full flex-1 rounded-xl border border-foreground/10 bg-[var(--bg-color,--alpha(var(--color-foreground)/10%))]"
style={
color
? {
'--bg-color': chroma
.hsv(color[0], color[1], color[2])
.css(),
}
: {}
}
/>
<div className="grid grid-cols-[auto,1fr] gap-x-2 gap-y-1 font-mono text-xs">
<span className="font-medium text-foreground-secondary">HEX</span>
<code className="rounded bg-background-secondary px-1.5 py-0.5">
{chroma.hsv(color[0], color[1], color[2]).hex().toUpperCase()}
</code>
<span className="font-medium text-foreground-secondary">RGB</span>
<code className="rounded bg-background-secondary px-1.5 py-0.5">
{chroma.hsv(color[0], color[1], color[2]).css()}
</code>
<span className="font-medium text-foreground-secondary">HSV</span>
<code className="rounded bg-background-secondary px-1.5 py-0.5">
{Math.round(color[0])}, {Math.round(color[1] * 100)}%,{' '}
{Math.round(color[2] * 100)}%
</code>
</div>
</div>
</div>
</div>
);
} Best Practices
-
Color Format:
- Use HSVA format internally for consistent color manipulation
- Convert to appropriate formats (HEX, RGB, HSL) for display or export
- Consider alpha channel support when needed
-
Layout and Composition:
- Combine different picker parts based on your use case
- Use Area + Hue for most common scenarios
- Add Alpha slider when transparency is needed
- Consider space constraints when choosing components
-
Accessibility:
- Ensure sufficient contrast between handles and backgrounds
- Test keyboard navigation thoroughly
- Provide clear labels for screen readers
- Consider users with color vision deficiencies
Previous
Checkbox
Next
Date Picker