A grid of preset color swatches with a scale-in selection ring, blur-swapped check, roving-tabindex keyboard navigation, and an optional custom hex input.
npx shadcn@latest add @paragon/color-swatch-picker"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const DEFAULT_COLORS = [
"#ef4444",
"#f97316",
"#f59e0b",
"#84cc16",
"#22c55e",
"#14b8a6",
"#0ea5e9",
"#6366f1",
"#a855f7",
"#ec4899",
];
export interface ColorSwatchPickerProps
extends Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "role"
> {
/** Preset swatches as hex strings. */
colors?: string[];
/** Controlled selected color (hex). */
value?: string;
/** Initial color when uncontrolled. */
defaultValue?: string;
onValueChange?: (color: string) => void;
columns?: number;
/** Show a custom hex input below the grid. */
withCustom?: boolean;
/** Form field name; renders a hidden input. */
name?: string;
disabled?: boolean;
}
/** Pick a readable check color for a given hex background. */
function contrastFor(hex: string): string {
let h = hex.replace("#", "");
if (h.length === 3) h = h.replace(/./g, (c) => c + c);
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.55 ? "#000000" : "#ffffff";
}
const isValidHex = (h: string) => /^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(h);
/**
* A grid of preset color swatches. The selection ring scales in, the check
* blur-swaps, and arrow keys move focus with a roving tabindex.
*/
export function ColorSwatchPicker({
colors = DEFAULT_COLORS,
value: valueProp,
defaultValue,
onValueChange,
columns = 5,
withCustom = false,
name,
disabled = false,
className,
"aria-label": ariaLabel = "Color",
...props
}: ColorSwatchPickerProps) {
const [uncontrolled, setUncontrolled] = React.useState(
(defaultValue ?? colors[0] ?? "").toLowerCase(),
);
const value = (valueProp ?? uncontrolled).toLowerCase();
const selectedIndex = colors.findIndex((c) => c.toLowerCase() === value);
const [focusIndex, setFocusIndex] = React.useState(
selectedIndex >= 0 ? selectedIndex : 0,
);
const [hexDraft, setHexDraft] = React.useState(value.replace("#", ""));
const swatchRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = (color: string) => {
const normalized = color.toLowerCase();
if (valueProp === undefined) setUncontrolled(normalized);
onValueChange?.(normalized);
setHexDraft(normalized.replace("#", ""));
};
const moveFocus = (next: number) => {
const clamped = Math.max(0, Math.min(colors.length - 1, next));
setFocusIndex(clamped);
swatchRefs.current[clamped]?.focus();
};
const commitHex = () => {
if (isValidHex(hexDraft)) select(`#${hexDraft.toLowerCase()}`);
else setHexDraft(value.replace("#", ""));
};
const customSelected = selectedIndex === -1 && value !== "";
return (
<div
className={cn("flex w-fit flex-col gap-3", className)}
{...props}
>
<div
role="radiogroup"
aria-label={ariaLabel}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "ArrowRight") {
e.preventDefault();
moveFocus(focusIndex + 1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
moveFocus(focusIndex - 1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
moveFocus(focusIndex + columns);
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveFocus(focusIndex - columns);
} else if (e.key === "Home") {
e.preventDefault();
moveFocus(0);
} else if (e.key === "End") {
e.preventDefault();
moveFocus(colors.length - 1);
}
}}
className="grid w-fit gap-2"
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{colors.map((color, i) => {
const normalized = color.toLowerCase();
const isSelected = normalized === value;
return (
<button
key={normalized}
ref={(node) => {
swatchRefs.current[i] = node;
}}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={normalized}
disabled={disabled}
tabIndex={i === focusIndex ? 0 : -1}
onFocus={() => setFocusIndex(i)}
onClick={() => select(color)}
className="relative flex size-7 items-center justify-center rounded-md transition-[scale] duration-150 ease-out after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-x-1/2 after:-translate-y-1/2 active:not-disabled:scale-[0.97] disabled:pointer-events-none disabled:opacity-50"
style={{ backgroundColor: color }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md ring-1 ring-black/10 ring-inset dark:ring-white/15"
/>
<span
aria-hidden
className={cn(
"pointer-events-none absolute -inset-1 rounded-[12px] border-2 transition-[scale,opacity] duration-150 ease-out motion-reduce:scale-100",
isSelected ? "scale-100 opacity-100" : "scale-75 opacity-0",
)}
style={{ borderColor: color }}
/>
<Check
aria-hidden
className={cn(
"size-4 transition-[opacity,scale,filter] duration-150 ease-out motion-reduce:scale-100 motion-reduce:blur-none",
isSelected
? "scale-100 opacity-100 blur-none"
: "scale-50 opacity-0 blur-[2px]",
)}
style={{ color: contrastFor(color) }}
/>
</button>
);
})}
</div>
{withCustom && (
<div className="flex items-center gap-2">
<span
aria-hidden
className={cn(
"relative flex size-7 shrink-0 items-center justify-center rounded-md transition-[opacity] duration-150",
!isValidHex(hexDraft) && "opacity-40",
)}
style={{
backgroundColor: isValidHex(hexDraft)
? `#${hexDraft}`
: "transparent",
}}
>
<span className="absolute inset-0 rounded-md ring-1 ring-black/10 ring-inset dark:ring-white/15" />
{customSelected && isValidHex(hexDraft) && (
<Check
className="size-4"
style={{ color: contrastFor(`#${hexDraft}`) }}
/>
)}
</span>
<div className="flex h-8 items-center gap-1 rounded-md border border-input px-2 transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30">
<span className="text-xs text-muted-foreground" aria-hidden>
#
</span>
<input
type="text"
aria-label="Custom hex color"
value={hexDraft}
maxLength={6}
spellCheck={false}
disabled={disabled}
onChange={(e) =>
setHexDraft(e.target.value.replace(/[^0-9a-fA-F]/g, ""))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitHex();
}
}}
onBlur={commitHex}
className="w-14 bg-transparent font-mono text-xs uppercase outline-none placeholder:text-muted-foreground"
placeholder="0EA5E9"
/>
</div>
</div>
)}
{name && <input type="hidden" name={name} value={value} />}
</div>
);
}