- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Label color
Overlay tint
Controlled#2563eb · #0ea5e9
Installation
$ pnpm dlx shadcn@latest add @cobalt/color-picker
Usage
import { useState } from "react";
import { ColorPicker, ColorWell } from "@/registry/cobalt/ui/color-picker";
const [color, setColor] = useState("#2563eb");
const [tint, setTint] = useState("#0ea5e9");
<ColorPicker value={color} onChange={setColor} />
<ColorWell
aria-label="Brand color"
value={color}
onChange={setColor}
swatches={["#1d4ed8", "#0ea5e9", "#10b981"]}
/>value and onChange speak hex strings, so a picker can be stored next to any
other colour field. The editor keeps the colour in HSB while you drag, so the
hue survives a trip through grey.
Props
ColorPicker
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled colour: hex, rgb() or hsl(). |
defaultValue | string | #000000 | Initial colour, when uncontrolled. |
onChange | (value: string) => void | — | Called with the colour as a hex string. |
showAlpha | boolean | false | Adds an opacity rail over a checkerboard. |
swatches | string[] | — | Preset colours in a row under the editor. |
className | string | — | Classes on the editor. It defaults to 228px. |
ColorWell
Takes every ColorPicker prop, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | Color | Names the swatch button and its popover. |
side | top | bottom | left | right | bottom | Which side the editor opens on. |
align | start | center | end | start | How the editor lines up with the swatch. |
wellClassName | string | — | Classes on the swatch button. |
popoverClassName | string | — | Classes on the popover surface. |
The editor opens in a portal, so pass a theme scope to popoverClassName when
the popover needs the same variables as its surroundings.
Source
"use client";
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cn } from "cn";
import * as React from "react";
import type { ColorPickerColor } from "@/components/lib/color-picker-color";
import {
colorToCss,
colorToHex,
colorToHsl,
parseColor,
} from "@/components/lib/color-picker-color";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
/**
* Color picker — choose a colour by eye or by hex: a brand colour, a label, a
* chart series.
*
* <ColorPicker value={color} onChange={setColor} />
* <ColorWell aria-label="Brand color" value={color} onChange={setColor} />
*
* A saturation and brightness field and a hue rail, both with the slider's
* white embossed thumb, and a hex row on the input's tertiary surface.
* `showAlpha` adds an opacity rail over a checkerboard, and `swatches` adds a
* row of presets. `ColorWell` is a round swatch that opens the same editor in a
* popover.
*
* The colour is kept in HSB while you drag, so its hue survives a trip through
* grey, each control is reachable by keyboard (arrows nudge, Page keys take
* bigger steps), and the hex field takes any CSS colour and commits on Enter or
* blur. Values are hex strings.
*/
const BLACK: ColorPickerColor = {
hue: 0,
saturation: 0,
brightness: 0,
alpha: 1,
};
const DEFAULT_COLOR = "#000000";
/** The slider's white embossed thumb, filled with the colour under it. */
const THUMB =
"size-4 rounded-full border border-white shadow-[0_2px_4px_rgb(0_0_0/0.18),inset_0_0_0_1px_rgb(0_0_0/0.06)] transition-[scale] duration-150 ease-out group-data-[dragging]/area:scale-110 data-dragging:scale-110 motion-reduce:transition-none";
/** The thumb's focus ring. The range input inside it takes the focus. */
const THUMB_RING =
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2";
/** The inset hairline that keeps light colours from melting into the page. */
const HAIRLINE =
"pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.06)]";
const CHECKERBOARD =
"bg-[repeating-conic-gradient(var(--color-picker-checker-alt,var(--muted))_0%_25%,var(--surface-card,var(--background))_0%_50%)] bg-[length:8px_8px]";
const FOCUS_RING =
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
interface ColorPickerContextValue {
color: ColorPickerColor;
setColor: (color: ColorPickerColor) => void;
}
const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(
null
);
function useColorPicker() {
const context = React.useContext(ColorPickerContext);
if (!context) {
throw new Error("ColorPicker parts must render inside <ColorPicker>.");
}
return context;
}
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
interface AreaThumbProps {
axis: "x" | "y";
color: ColorPickerColor;
inputRef?: React.Ref<HTMLInputElement>;
label: string;
left: number;
top: number;
value: number;
tabIndex: number;
focusRing: boolean;
onFocus: () => void;
onBlur: () => void;
onValueChange: (value: number) => void;
}
/** One axis of the saturation and brightness field: a thumb with a hidden
* range input, so arrows nudge and Page keys take bigger steps. */
function AreaThumb({
axis,
color,
inputRef,
label,
left,
top,
value,
tabIndex,
focusRing,
onFocus,
onBlur,
onValueChange,
}: AreaThumbProps) {
return (
<div
role="presentation"
className={cn(
THUMB,
focusRing && THUMB_RING,
"absolute -translate-x-1/2 -translate-y-1/2"
)}
style={{
left: `${left}%`,
top: `${top}%`,
backgroundColor: colorToCss(color),
}}
>
<input
ref={inputRef}
type="range"
min={0}
max={100}
step={1}
value={Math.round(value)}
aria-label={label}
aria-orientation={axis === "y" ? "vertical" : "horizontal"}
tabIndex={tabIndex}
onChange={(event) => onValueChange(Number(event.target.value))}
onFocus={onFocus}
onBlur={onBlur}
className="pointer-events-none absolute inset-0 size-full appearance-none opacity-0"
/>
</div>
);
}
/** The saturation and brightness field. */
function ColorArea() {
const { color, setColor } = useColorPicker();
const areaRef = React.useRef<HTMLDivElement>(null);
const saturationRef = React.useRef<HTMLInputElement>(null);
const brightnessRef = React.useRef<HTMLInputElement>(null);
const draggingRef = React.useRef(false);
const [isDragging, setIsDragging] = React.useState(false);
const [focusedAxis, setFocusedAxis] = React.useState<"x" | "y" | null>(null);
// A scripted focus after a pointer press still matches :focus-visible in
// Chrome, so track the modality ourselves and keep the ring for the keyboard.
const [pointerFocus, setPointerFocus] = React.useState(false);
const { hue } = colorToHsl(color);
const background = [
"linear-gradient(to top, black, transparent)",
"linear-gradient(to right, hsl(0 0% 100%), transparent)",
`hsl(${hue} 100% 50%)`,
].join(", ");
function setFromPoint(clientX: number, clientY: number) {
const rect = areaRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const x = clamp((clientX - rect.left) / rect.width, 0, 1);
const y = clamp((clientY - rect.top) / rect.height, 0, 1);
setColor({
...color,
saturation: Math.round(x * 100),
brightness: Math.round((1 - y) * 100),
});
}
function endDrag() {
draggingRef.current = false;
setIsDragging(false);
}
// The field is one two-dimensional control: arrows nudge either axis and
// Page keys take bigger steps, so each axis has its own hidden range input.
function nudge(event: React.KeyboardEvent<HTMLDivElement>) {
const step = event.shiftKey ? 10 : 1;
let next = color;
let axis: "x" | "y" = "x";
switch (event.key) {
case "ArrowLeft": {
next = { ...color, saturation: clamp(color.saturation - step, 0, 100) };
break;
}
case "ArrowRight": {
next = { ...color, saturation: clamp(color.saturation + step, 0, 100) };
break;
}
case "ArrowUp": {
next = { ...color, brightness: clamp(color.brightness + step, 0, 100) };
axis = "y";
break;
}
case "ArrowDown": {
next = { ...color, brightness: clamp(color.brightness - step, 0, 100) };
axis = "y";
break;
}
case "PageUp": {
next = { ...color, brightness: clamp(color.brightness + 10, 0, 100) };
axis = "y";
break;
}
case "PageDown": {
next = { ...color, brightness: clamp(color.brightness - 10, 0, 100) };
axis = "y";
break;
}
case "Home": {
next = { ...color, saturation: 0 };
break;
}
case "End": {
next = { ...color, saturation: 100 };
break;
}
default: {
return;
}
}
event.preventDefault();
setPointerFocus(false);
setColor(next);
(axis === "x" ? saturationRef : brightnessRef).current?.focus();
}
const top = 100 - color.brightness;
return (
<div
ref={areaRef}
role="group"
aria-label="Color"
data-dragging={isDragging || undefined}
style={{ background }}
className="group/area relative aspect-[3/2] w-full shrink-0 cursor-crosshair touch-none rounded-[16px]"
onPointerDown={(event) => {
if (
event.pointerType === "mouse" &&
(event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey)
) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
draggingRef.current = true;
setIsDragging(true);
setPointerFocus(true);
saturationRef.current?.focus();
setFromPoint(event.clientX, event.clientY);
}}
onKeyDown={nudge}
onPointerMove={(event) => {
if (!draggingRef.current) {
return;
}
setFromPoint(event.clientX, event.clientY);
}}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
endDrag();
}}
onPointerCancel={endDrag}
>
<span aria-hidden className={cn(HAIRLINE, "rounded-[16px]")} />
<AreaThumb
axis="x"
color={color}
inputRef={saturationRef}
label="Saturation"
left={color.saturation}
top={top}
value={color.saturation}
tabIndex={focusedAxis === "y" ? -1 : 0}
focusRing={!pointerFocus}
onFocus={() => setFocusedAxis("x")}
onBlur={() => {
setFocusedAxis(null);
setPointerFocus(false);
}}
onValueChange={(saturation) => setColor({ ...color, saturation })}
/>
<AreaThumb
axis="y"
color={color}
inputRef={brightnessRef}
label="Brightness"
left={color.saturation}
top={top}
value={color.brightness}
tabIndex={focusedAxis === "y" ? 0 : -1}
focusRing={!pointerFocus}
onFocus={() => setFocusedAxis("y")}
onBlur={() => {
setFocusedAxis(null);
setPointerFocus(false);
}}
onValueChange={(brightness) => setColor({ ...color, brightness })}
/>
</div>
);
}
/** The hue rail, or the opacity rail over a checkerboard. */
function ColorSlider({ channel }: { channel: "hue" | "alpha" }) {
const { color, setColor } = useColorPicker();
const label = channel === "hue" ? "Hue" : "Alpha";
const value =
channel === "hue" ? Math.round(color.hue) : Math.round(color.alpha * 100);
const max = channel === "hue" ? 360 : 100;
const background =
channel === "hue"
? `linear-gradient(to right, ${[0, 60, 120, 180, 240, 300, 360]
.map((stop) => `hsl(${stop} 100% 50%)`)
.join(", ")})`
: `linear-gradient(to right, ${colorToCss(color, 0)}, ${colorToCss(color, 1)})`;
const slider = (
<SliderPrimitive.Control className="relative flex h-3 w-full touch-none items-center select-none">
<SliderPrimitive.Track
className="relative h-3 w-full cursor-pointer rounded-full"
style={{ background }}
>
<span aria-hidden className={cn(HAIRLINE, "rounded-full")} />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
getAriaLabel={() => label}
className={cn(THUMB, THUMB_RING, "top-1/2")}
style={{ backgroundColor: colorToCss(color) }}
/>
</SliderPrimitive.Control>
);
return (
<SliderPrimitive.Root
value={value}
min={0}
max={max}
step={1}
className="w-full"
onValueChange={(next) => {
const channelValue = Array.isArray(next) ? next[0] : next;
setColor(
channel === "hue"
? { ...color, hue: channelValue }
: { ...color, alpha: channelValue / 100 }
);
}}
>
{channel === "hue" ? (
slider
) : (
<div className={cn("relative h-3 w-full rounded-full", CHECKERBOARD)}>
{slider}
</div>
)}
</SliderPrimitive.Root>
);
}
/** The current colour, a round swatch. */
function ColorSwatch({ className }: { className?: string }) {
const { color } = useColorPicker();
return (
<span
aria-hidden
className={cn(
"block size-8 shrink-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.06)]",
className
)}
style={{ backgroundColor: colorToCss(color) }}
/>
);
}
/** The hex field on the input's tertiary surface; commits on Enter or blur. */
function ColorField() {
const { color, setColor } = useColorPicker();
const hex = colorToHex(color, false);
const [text, setText] = React.useState(hex);
React.useEffect(() => setText(hex), [hex]);
function commit() {
const parsed = parseColor(text);
if (!parsed) {
setText(hex);
return;
}
setColor({ ...parsed, alpha: color.alpha });
}
return (
<input
type="text"
dir="ltr"
spellCheck={false}
autoComplete="off"
aria-label="Hex color"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
// React Aria's field commits on blur only; Enter commits it too, and
// focus stays in the field.
if (event.key !== "Enter") {
return;
}
event.preventDefault();
commit();
}}
className={cn(
"h-8 w-full min-w-0 rounded-[10px] bg-[var(--surface-strong,var(--muted))] px-2.5 font-mono text-[13px] text-foreground uppercase",
"ring-2 ring-transparent ring-inset outline-none transition-[background-color,box-shadow] duration-150 ease-[ease]",
// cobalt-allow-focus-within -- text input (hex field)
"focus:bg-popover focus:ring-ring",
"motion-reduce:transition-none"
)}
/>
);
}
/** Preset colours in a row under the editor. */
function ColorSwatches({ swatches }: { swatches: string[] }) {
const { color, setColor } = useColorPicker();
const current = colorToHex(color, true);
return (
<div
role="group"
aria-label="Presets"
className="grid grid-cols-7 gap-1.5 pt-0.5"
>
{swatches.map((swatch) => {
const parsed = parseColor(swatch);
const selected =
parsed !== null && colorToHex(parsed, true) === current;
return (
<button
key={swatch}
type="button"
aria-label={swatch}
aria-pressed={selected}
data-selected={selected || undefined}
onClick={() => {
if (parsed) {
setColor({ ...parsed, alpha: color.alpha });
}
}}
className={cn(
"group/swatch relative aspect-square w-full cursor-pointer rounded-full outline-none transition-[scale] duration-150 ease-out hover:scale-110 motion-reduce:transition-none",
FOCUS_RING
)}
>
<span
aria-hidden
className="block size-full rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)]"
style={{ backgroundColor: swatch }}
/>
{/* Selected: a white ring inside a dark hairline reads on any colour. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full opacity-0 shadow-[inset_0_0_0_2px_#fff,inset_0_0_0_3px_rgb(0_0_0/0.25)] transition-opacity duration-150 group-data-[selected]/swatch:opacity-100 motion-reduce:transition-none"
/>
</button>
);
})}
</div>
);
}
interface EditorProps {
showAlpha?: boolean;
swatches?: string[];
className?: string;
}
/** The controls, bound to the nearest ColorPicker. */
function Editor({ showAlpha = false, swatches, className }: EditorProps) {
return (
<div
className={cn("flex w-[228px] max-w-full flex-col gap-2.5", className)}
>
<ColorArea />
<ColorSlider channel="hue" />
{showAlpha ? <ColorSlider channel="alpha" /> : null}
<div className="flex items-center gap-2">
<ColorSwatch />
<ColorField />
</div>
{swatches?.length ? <ColorSwatches swatches={swatches} /> : null}
</div>
);
}
export interface ColorPickerProps {
/** Controlled colour: hex, rgb() or hsl(). */
value?: string;
/** Initial colour, when uncontrolled. Default #000000. */
defaultValue?: string;
/** Called with the colour as a hex string. */
onChange?: (value: string) => void;
/** Add an opacity rail. Default false. */
showAlpha?: boolean;
/** Preset colours, as CSS colour strings, in a row under the editor. */
swatches?: string[];
/** Classes on the editor, e.g. a width. Default 228px. */
className?: string;
}
function ColorPickerRoot({
value,
defaultValue,
onChange,
children,
}: ColorPickerProps & { children: React.ReactNode }) {
const [internal, setInternal] = React.useState<ColorPickerColor>(
() => parseColor(defaultValue ?? DEFAULT_COLOR) ?? BLACK
);
const controlled = value === undefined ? null : parseColor(value);
// Keep the HSB state, and with it the hue, when the parent hands back the
// hex we just emitted, so the hue still survives a trip through grey.
const color =
controlled && colorToHex(internal, true) !== colorToHex(controlled, true)
? controlled
: internal;
const setColor = React.useCallback(
(next: ColorPickerColor) => {
setInternal(next);
onChange?.(colorToHex(next));
},
[onChange]
);
return (
<ColorPickerContext.Provider value={{ color, setColor }}>
{children}
</ColorPickerContext.Provider>
);
}
export function ColorPicker({
showAlpha,
swatches,
className,
...props
}: ColorPickerProps) {
return (
<ColorPickerRoot {...props}>
<Editor showAlpha={showAlpha} swatches={swatches} className={className} />
</ColorPickerRoot>
);
}
export interface ColorWellProps extends ColorPickerProps {
/** Names the swatch button and its popover. Default "Color". */
"aria-label"?: string;
/** Which side the editor opens on. Default "bottom". */
side?: PopoverSide;
/** How the editor lines up with the swatch. Default "start". */
align?: PopoverAlign;
/** Classes on the swatch button, e.g. a size. Default 32px. */
wellClassName?: string;
/** Classes on the popover surface, e.g. a theme scope. */
popoverClassName?: string;
}
type PopoverSide = "top" | "bottom" | "left" | "right";
type PopoverAlign = "start" | "center" | "end";
/** A round swatch that opens the editor in a popover. */
export function ColorWell({
"aria-label": ariaLabel = "Color",
side = "bottom",
align = "start",
showAlpha,
swatches,
className,
wellClassName,
popoverClassName,
...props
}: ColorWellProps) {
return (
<ColorPickerRoot {...props}>
<Popover>
<PopoverTrigger
aria-label={ariaLabel}
className={cn(
"relative size-8 shrink-0 cursor-pointer rounded-full outline-none transition-[scale] duration-150 ease-out hover:scale-110 motion-reduce:transition-none",
FOCUS_RING,
wellClassName
)}
>
<ColorSwatch className="size-full rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)]" />
</PopoverTrigger>
<PopoverContent
aria-label={ariaLabel}
side={side}
align={align}
className={cn(
"w-[248px] gap-0 rounded-[24px] p-2.5",
popoverClassName
)}
>
<Editor
showAlpha={showAlpha}
swatches={swatches}
className={cn("w-full", className)}
/>
</PopoverContent>
</Popover>
</ColorPickerRoot>
);
} On this page