Color Picker
A saturation area, hue slider, hex input and suggested swatches for choosing a color.
ui/color-picker.tsx
"use client";
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { CheckIcon, EyedropperIcon } from "blode-icons-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
/** A named colour offered in the suggested grid. */
export interface ColorSwatch {
/** Human-readable name, used as the swatch's accessible name. */
name: string;
/** Hex value, e.g. `#D9544B`. */
value: string;
}
/**
* Six neutrals and a twelve-step spectrum held at a constant OKLCH lightness
* and chroma, so no single hue jumps forward in the grid. These are data, not
* theme tokens: the colour a user picks belongs to their content, not to Blode.
*/
const defaultColorSwatches: ColorSwatch[] = [
{ name: "Black", value: "#0A0A0A" },
{ name: "Graphite", value: "#333333" },
{ name: "Slate", value: "#636363" },
{ name: "Silver", value: "#989898" },
{ name: "Mist", value: "#D7D7D7" },
{ name: "White", value: "#FFFFFF" },
{ name: "Red", value: "#D9544B" },
{ name: "Orange", value: "#CF6400" },
{ name: "Amber", value: "#B27C00" },
{ name: "Lime", value: "#809100" },
{ name: "Green", value: "#24A042" },
{ name: "Teal", value: "#00A584" },
{ name: "Cyan", value: "#009FB9" },
{ name: "Azure", value: "#0091DE" },
{ name: "Blue", value: "#587EEB" },
{ name: "Violet", value: "#936BDE" },
{ name: "Magenta", value: "#BA5BBB" },
{ name: "Rose", value: "#D25188" },
];
const SWATCH_COLUMNS = 6;
const HEX_PATTERN = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu;
const SRGB_LINEAR_CUTOFF = 0.04045;
const MAX_HUE = 360;
const HUE_SECTOR = 60;
const PERCENT = 100;
/** Expands `#RGB` to `#RRGGBB` and upper-cases it. Returns null when unparseable. */
const normalizeHex = (input: string): string | null => {
const trimmed = input.trim();
if (!HEX_PATTERN.test(trimmed)) {
return null;
}
const digits = trimmed.replace("#", "");
const expanded =
digits.length === 3 ? [...digits].map((digit) => digit + digit).join("") : digits;
return `#${expanded.toUpperCase()}`;
};
const toChannels = (hex: string): [number, number, number] => {
const digits = hex.replace("#", "");
return [0, 2, 4].map((offset) => Number.parseInt(digits.slice(offset, offset + 2), 16) / 255) as [
number,
number,
number,
];
};
/**
* Near-black or near-white ink for the selected marker, chosen from the
* swatch's own luminance. The swatch is arbitrary user data, so no token can
* know which way to go.
*/
const readableInk = (hex: string): string => {
const [red, green, blue] = toChannels(hex).map((channel) =>
channel <= SRGB_LINEAR_CUTOFF ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue > 0.4 ? "#0A0A0A" : "#FFFFFF";
};
/** Hue in degrees, saturation and value as percentages. */
interface Hsv {
h: number;
s: number;
v: number;
}
const hexToHsv = (hex: string): Hsv => {
const [red, green, blue] = toChannels(hex);
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const span = max - min;
let sextant = 0;
if (span !== 0) {
if (max === red) {
sextant = (green - blue) / span;
} else if (max === green) {
sextant = (blue - red) / span + 2;
} else {
sextant = (red - green) / span + 4;
}
}
return {
h: (sextant * HUE_SECTOR + MAX_HUE) % MAX_HUE,
s: max === 0 ? 0 : (span / max) * PERCENT,
v: max * PERCENT,
};
};
const hsvToHex = ({ h, s, v }: Hsv): string => {
const saturation = s / PERCENT;
const value = v / PERCENT;
const chroma = value * saturation;
const sector = (((h % MAX_HUE) + MAX_HUE) % MAX_HUE) / HUE_SECTOR;
const mid = chroma * (1 - Math.abs((sector % 2) - 1));
const ramps: [number, number, number][] = [
[chroma, mid, 0],
[mid, chroma, 0],
[0, chroma, mid],
[0, mid, chroma],
[mid, 0, chroma],
[chroma, 0, mid],
];
const base = value - chroma;
const channels = ramps[Math.floor(sector) % 6].map((channel) =>
Math.round((channel + base) * 255)
.toString(16)
.padStart(2, "0"),
);
return `#${channels.join("")}`.toUpperCase();
};
const clamp01 = (input: number) => Math.min(1, Math.max(0, input));
interface EyeDropperResult {
sRGBHex: string;
}
type EyeDropperConstructor = new () => {
open: (options?: { signal?: AbortSignal }) => Promise<EyeDropperResult>;
};
const noop = () => {
// Nothing to unsubscribe from: the capability never changes at runtime.
};
const noopSubscribe = () => noop;
/**
* Feature-detected through `useSyncExternalStore` rather than an effect, so the
* server snapshot is `false` and hydration matches before the real answer
* arrives on the client.
*/
const useHasEyeDropper = () =>
React.useSyncExternalStore(
noopSubscribe,
() => "EyeDropper" in window,
() => false,
);
/**
* The white ring alone disappears against the white corner of the saturation
* area, so every thumb carries a dark outer ring as well.
*/
const THUMB_CLASSES =
"pointer-events-none rounded-full border-2 border-white bg-transparent ring-1 ring-black/25 shadow-sm";
export interface ColorPickerProps {
/** Accessible name for the trigger. The current value is appended to it. */
"aria-label"?: string;
/** Class names merged onto the trigger button. */
className?: string;
/** Disables the trigger and the whole popover. */
disabled?: boolean;
/** Id applied to the trigger button, for a `FieldLabel htmlFor`. */
id?: string;
/**
* Called once a gesture settles — pointer release, `Enter` or blur in the hex
* field, a swatch click. Use it for writes too expensive to run per frame.
*/
onValueCommit?: (value: string) => void;
/** Called with a normalised `#RRGGBB` string whenever the colour changes. */
onValueChange?: (value: string) => void;
/** Swatches offered under “Suggested”. Defaults to Blode's neutral and spectrum set. */
swatches?: ColorSwatch[];
/** The selected colour as a hex string. */
value: string;
}
const ColorPicker = ({
"aria-label": ariaLabel = "Colour",
className,
disabled = false,
id,
onValueChange,
onValueCommit,
swatches = defaultColorSwatches,
value,
}: ColorPickerProps) => {
const parsedValue = normalizeHex(value);
// White is only a render fallback. Without the flag below an unparseable
// value looks like a deliberate white, hiding the caller's mistake.
const normalizedValue = parsedValue ?? "#FFFFFF";
const valueIsInvalid = parsedValue === null;
const hasEyeDropper = useHasEyeDropper();
const [open, setOpen] = React.useState(false);
// `null` means "mirror the committed value", so a new `value` prop shows up
// in the input without an effect syncing the two.
const [draft, setDraft] = React.useState<string | null>(null);
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);
const swatchRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const areaRef = React.useRef<HTMLDivElement | null>(null);
const saturationRef = React.useRef<HTMLInputElement | null>(null);
// HSV is the working state, not a value derived per render. Black and white
// carry no hue, so re-deriving would snap the rail back to red the moment a
// user dragged into either corner.
const [hsv, setHsv] = React.useState<Hsv>(() => hexToHsv(normalizedValue));
const [syncedValue, setSyncedValue] = React.useState(normalizedValue);
if (syncedValue !== normalizedValue) {
setSyncedValue(normalizedValue);
// Only re-derive when the new colour is genuinely a different one; echoing
// our own hex back must not round-trip away the hue we are holding.
if (hsvToHex(hsv) !== normalizedValue) {
setHsv(hexToHsv(normalizedValue));
}
}
const selectedIndex = swatches.findIndex(
(swatch) => (normalizeHex(swatch.value) ?? swatch.value) === normalizedValue,
);
const activeIndex = focusedIndex ?? Math.max(0, selectedIndex);
const draftValue = draft ?? normalizedValue;
const draftIsInvalid = draftValue.trim() !== "" && normalizeHex(draftValue) === null;
const applyHsv = (next: Hsv) => {
const hex = hsvToHex(next);
setHsv(next);
setSyncedValue(hex);
setDraft(null);
onValueChange?.(hex);
return hex;
};
const commitHsv = (next: Hsv) => {
onValueCommit?.(applyHsv(next));
};
const applyHex = (hex: string) => {
const next = hexToHsv(hex);
setHsv(next);
setSyncedValue(hex);
setDraft(null);
onValueChange?.(hex);
onValueCommit?.(hex);
};
const focusSwatch = (index: number) => {
const clamped = Math.max(0, Math.min(swatches.length - 1, index));
setFocusedIndex(clamped);
swatchRefs.current[clamped]?.focus();
};
const handleSwatchKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const moves: Record<string, number> = {
ArrowDown: index + SWATCH_COLUMNS,
ArrowLeft: index - 1,
ArrowRight: index + 1,
ArrowUp: index - SWATCH_COLUMNS,
End: swatches.length - 1,
Home: 0,
};
const next = moves[event.key];
if (next === undefined) {
return;
}
event.preventDefault();
focusSwatch(next);
};
const commitDraft = () => {
const normalized = normalizeHex(draftValue);
// An unparseable entry reverts rather than clearing the colour, so a
// half-typed hex never destroys the caller's value.
setDraft(null);
if (normalized) {
applyHex(normalized);
}
};
const pickFromScreen = async () => {
const { EyeDropper } = window as unknown as { EyeDropper?: EyeDropperConstructor };
if (!EyeDropper) {
return;
}
try {
const result = await new EyeDropper().open();
const normalized = normalizeHex(result.sRGBHex);
if (normalized) {
applyHex(normalized);
}
} catch {
// The picker was dismissed; the current colour stands.
}
};
const trackPointer = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = areaRef.current?.getBoundingClientRect();
if (!rect) {
return hsv;
}
const next = {
h: hsv.h,
s: clamp01((event.clientX - rect.left) / rect.width) * PERCENT,
v: (1 - clamp01((event.clientY - rect.top) / rect.height)) * PERCENT,
};
applyHsv(next);
return next;
};
const hueColor = hsvToHex({ h: hsv.h, s: PERCENT, v: PERCENT });
const areaValueText = `${Math.round(hsv.s)}% saturation, ${Math.round(hsv.v)}% brightness, ${normalizedValue}`;
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
aria-invalid={valueIsInvalid || undefined}
aria-label={
valueIsInvalid ? `${ariaLabel}, invalid value` : `${ariaLabel}, ${normalizedValue}`
}
data-invalid={valueIsInvalid || undefined}
disabled={disabled}
id={id}
render={<Button className={cn("justify-start", className)} size="input" variant="input" />}
>
<span
aria-hidden="true"
className="size-5 shrink-0 rounded-sm inset-ring-1 inset-ring-foreground/15"
data-slot="color-picker-swatch"
style={{ backgroundColor: normalizedValue }}
/>
<span className="tabular-figures text-sm">{normalizedValue}</span>
</PopoverTrigger>
<PopoverContent
align="start"
className="flex w-64 flex-col gap-3 p-3"
data-slot="color-picker-content"
>
{/* Two hidden range inputs rather than a keydown handler on the div:
each axis then carries real slider semantics, and arrow keys, Home,
End and Page Up/Down all come from the platform. */}
{/* oxlint-disable jsx-a11y/prefer-tag-over-role -- a <fieldset> is the rule's suggested tag, but its anonymous content box makes the thumb's percentage `top` resolve against an indefinite height, pinning the thumb to 0 while `left` resolves normally */}
<div
aria-label={`${ariaLabel} saturation and brightness`}
className="relative h-40 w-full touch-none select-none rounded-sm inset-ring-1 inset-ring-foreground/10"
data-slot="color-picker-area"
role="group"
onPointerDown={(event) => {
if (disabled) {
return;
}
// Suppressing the compatibility mousedown stops the popup from
// pulling focus onto itself, which would undo the focus() below.
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
// The gradient is not itself focusable, so without this a drag
// leaves focus behind and arrow keys cannot continue the gesture.
saturationRef.current?.focus();
trackPointer(event);
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
trackPointer(event);
}
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
commitHsv(trackPointer(event));
}}
ref={areaRef}
style={{
backgroundColor: hueColor,
backgroundImage:
"linear-gradient(0deg, #000000, transparent), linear-gradient(90deg, #FFFFFF, transparent)",
}}
>
<input
aria-label="Saturation"
aria-valuetext={areaValueText}
className="peer/saturation sr-only"
disabled={disabled}
max={PERCENT}
min={0}
onBlur={() => onValueCommit?.(hsvToHex(hsv))}
onChange={(event) => applyHsv({ ...hsv, s: event.target.valueAsNumber })}
ref={saturationRef}
step={1}
type="range"
value={Math.round(hsv.s)}
/>
<input
aria-label="Brightness"
aria-valuetext={areaValueText}
className="peer/brightness sr-only"
disabled={disabled}
max={PERCENT}
min={0}
onChange={(event) => applyHsv({ ...hsv, v: event.target.valueAsNumber })}
onBlur={() => onValueCommit?.(hsvToHex(hsv))}
step={1}
type="range"
value={Math.round(hsv.v)}
/>
<span
className={cn(
THUMB_CLASSES,
"absolute size-5 -translate-x-1/2 -translate-y-1/2 peer-focus-visible/brightness:outline-2 peer-focus-visible/brightness:outline-ring peer-focus-visible/brightness:outline-offset-2 peer-focus-visible/saturation:outline-2 peer-focus-visible/saturation:outline-ring peer-focus-visible/saturation:outline-offset-2",
)}
data-slot="color-picker-area-thumb"
style={{ left: `${hsv.s}%`, top: `${PERCENT - hsv.v}%` }}
/>
</div>
{/* oxlint-enable jsx-a11y/prefer-tag-over-role */}
<SliderPrimitive.Root
disabled={disabled}
largeStep={10}
max={MAX_HUE}
min={0}
onValueChange={(next) => applyHsv({ ...hsv, h: next as number })}
onValueCommitted={(next) => commitHsv({ ...hsv, h: next as number })}
step={1}
value={Math.round(hsv.h)}
>
<SliderPrimitive.Control
className="relative flex h-6 w-full touch-none select-none items-center data-disabled:opacity-50"
data-slot="color-picker-hue"
>
{/* Literal sRGB stops: this is the hue wheel itself, not chrome, so
no theme token can stand in for it. */}
<SliderPrimitive.Track className="relative h-3 w-full rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)] inset-ring-1 inset-ring-foreground/10" />
<SliderPrimitive.Thumb
aria-label="Hue"
className={cn(
THUMB_CLASSES,
"pointer-events-auto size-6 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2",
)}
data-slot="color-picker-hue-thumb"
style={{ backgroundColor: hueColor }}
/>
</SliderPrimitive.Control>
</SliderPrimitive.Root>
<div className="flex items-center gap-2" data-slot="color-picker-custom">
<Input
aria-invalid={draftIsInvalid || undefined}
aria-label="Hex colour"
autoComplete="off"
className="tabular-figures h-9 rounded-lg text-sm"
disabled={disabled}
maxLength={7}
onBlur={commitDraft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitDraft();
}
}}
spellCheck={false}
value={draftValue}
/>
{hasEyeDropper && (
<Button
aria-label="Pick a colour from the screen"
disabled={disabled}
onClick={pickFromScreen}
size="icon-sm"
type="button"
variant="outline"
>
<EyedropperIcon />
</Button>
)}
</div>
{swatches.length > 0 && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span
className="text-muted-foreground text-xs"
id={`${id ?? "color-picker"}-suggested`}
>
Suggested
</span>
{/* oxlint-disable jsx-a11y/prefer-tag-over-role -- role="radio" on a button keeps grid arrow-keys (Up/Down move a row, not one swatch) and Enter/Space-to-select; a native radio group moves linearly and selects on focus, which would change the colour while merely browsing it */}
<div
aria-labelledby={`${id ?? "color-picker"}-suggested`}
className="grid grid-cols-6 gap-2"
data-slot="color-picker-swatches"
role="radiogroup"
>
{swatches.map((swatch, index) => {
const swatchValue = normalizeHex(swatch.value) ?? swatch.value;
const isSelected = swatchValue === normalizedValue;
return (
<button
aria-checked={isSelected}
aria-label={swatch.name}
className={cn(
"relative flex size-7 items-center justify-center rounded-sm outline-none inset-ring-1 inset-ring-foreground/15 transition-[box-shadow,transform] duration-150 ease-out hover:scale-105 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2",
isSelected && "ring-2 ring-foreground ring-offset-2 ring-offset-popover",
)}
disabled={disabled}
key={`${swatch.name}-${swatch.value}`}
// A swatch is a starting point to refine, not a final
// answer, so selecting one leaves the popover open.
onClick={() => applyHex(swatchValue)}
onKeyDown={(event) => handleSwatchKeyDown(event, index)}
ref={(node) => {
swatchRefs.current[index] = node;
}}
role="radio"
style={{ backgroundColor: swatchValue }}
tabIndex={index === activeIndex ? 0 : -1}
type="button"
>
{/* A ring alone would be the only cue on a light swatch, so the
selected state also carries a mark. */}
{isSelected && (
<CheckIcon
className="size-3.5"
data-slot="color-picker-swatch-indicator"
style={{ color: readableInk(swatchValue) }}
/>
)}
</button>
);
})}
</div>
{/* oxlint-enable jsx-a11y/prefer-tag-over-role */}
</div>
</>
)}
</PopoverContent>
</Popover>
);
};
export { ColorPicker, defaultColorSwatches, hexToHsv, hsvToHex, normalizeHex };
- Interaction
- carousel
DependenciesBundle weight · light
- ui-primitives
- @base-ui/react