Colour Swatch Picker
Original · freeswatch colour picker
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/color-swatch-picker.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type Shade = { step: number; hex: string };
type Family = { name: string; shades: Shade[] };
type Cell = { r: number; c: number };
type Format = "hex" | "rgb" | "hsl";
type CopyState = "idle" | "done" | "error";
type Rgb = { r: number; g: number; b: number };
const STEPS = [200, 300, 400, 500, 600, 700] as const;
const FAMILIES: Family[] = [
{
name: "Indigo",
shades: [
{ step: 200, hex: "#c7d2fe" },
{ step: 300, hex: "#a5b4fc" },
{ step: 400, hex: "#818cf8" },
{ step: 500, hex: "#6366f1" },
{ step: 600, hex: "#4f46e5" },
{ step: 700, hex: "#4338ca" },
],
},
{
name: "Violet",
shades: [
{ step: 200, hex: "#ddd6fe" },
{ step: 300, hex: "#c4b5fd" },
{ step: 400, hex: "#a78bfa" },
{ step: 500, hex: "#8b5cf6" },
{ step: 600, hex: "#7c3aed" },
{ step: 700, hex: "#6d28d9" },
],
},
{
name: "Sky",
shades: [
{ step: 200, hex: "#bae6fd" },
{ step: 300, hex: "#7dd3fc" },
{ step: 400, hex: "#38bdf8" },
{ step: 500, hex: "#0ea5e9" },
{ step: 600, hex: "#0284c7" },
{ step: 700, hex: "#0369a1" },
],
},
{
name: "Emerald",
shades: [
{ step: 200, hex: "#a7f3d0" },
{ step: 300, hex: "#6ee7b7" },
{ step: 400, hex: "#34d399" },
{ step: 500, hex: "#10b981" },
{ step: 600, hex: "#059669" },
{ step: 700, hex: "#047857" },
],
},
{
name: "Amber",
shades: [
{ step: 200, hex: "#fde68a" },
{ step: 300, hex: "#fcd34d" },
{ step: 400, hex: "#fbbf24" },
{ step: 500, hex: "#f59e0b" },
{ step: 600, hex: "#d97706" },
{ step: 700, hex: "#b45309" },
],
},
{
name: "Rose",
shades: [
{ step: 200, hex: "#fecdd3" },
{ step: 300, hex: "#fda4af" },
{ step: 400, hex: "#fb7185" },
{ step: 500, hex: "#f43f5e" },
{ step: 600, hex: "#e11d48" },
{ step: 700, hex: "#be123c" },
],
},
{
name: "Slate",
shades: [
{ step: 200, hex: "#e2e8f0" },
{ step: 300, hex: "#cbd5e1" },
{ step: 400, hex: "#94a3b8" },
{ step: 500, hex: "#64748b" },
{ step: 600, hex: "#475569" },
{ step: 700, hex: "#334155" },
],
},
];
const ROWS = FAMILIES.length;
const COLS = STEPS.length;
const INK = "#0b1120";
const PAPER = "#ffffff";
function hexToRgb(hex: string): Rgb {
const h = hex.replace("#", "");
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
};
}
function rgbToHsl({ r, g, b }: Rgb): { h: number; s: number; l: number } {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
const d = max - min;
if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) };
const s = d / (1 - Math.abs(2 * l - 1));
let h: number;
if (max === rn) h = ((gn - bn) / d) % 6;
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
return { h, s: Math.round(s * 100), l: Math.round(l * 100) };
}
function channelLuminance(v: number): number {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminance(hex: string): number {
const { r, g, b } = hexToRgb(hex);
return (
0.2126 * channelLuminance(r) +
0.7152 * channelLuminance(g) +
0.0722 * channelLuminance(b)
);
}
function contrastRatio(a: string, b: string): number {
const la = luminance(a);
const lb = luminance(b);
const hi = Math.max(la, lb);
const lo = Math.min(la, lb);
return (hi + 0.05) / (lo + 0.05);
}
function grade(ratio: number): { label: string; tone: "pass" | "large" | "fail" } {
if (ratio >= 7) return { label: "AAA", tone: "pass" };
if (ratio >= 4.5) return { label: "AA", tone: "pass" };
if (ratio >= 3) return { label: "AA Large", tone: "large" };
return { label: "Fail", tone: "fail" };
}
function readableOn(hex: string): string {
return contrastRatio(hex, PAPER) >= 3 ? PAPER : INK;
}
function formatValue(hex: string, format: Format): string {
if (format === "hex") return hex.toUpperCase();
const rgb = hexToRgb(hex);
if (format === "rgb") return `rgb(${rgb.r} ${rgb.g} ${rgb.b})`;
const { h, s, l } = rgbToHsl(rgb);
return `hsl(${h} ${s}% ${l}%)`;
}
function CheckIcon({ color }: { color: string }) {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke={color}
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.5 10.5 8.2 14.2 15.5 6.4" />
</svg>
);
}
function CopyIcon() {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="7" y="7" width="9" height="9" rx="2" />
<path d="M13 4.5H6a1.5 1.5 0 0 0-1.5 1.5v7" />
</svg>
);
}
export default function ColorSwatchPicker() {
const uid = useId();
const reduced = useReducedMotion();
const [active, setActive] = useState<Cell>({ r: 0, c: 3 });
const [format, setFormat] = useState<Format>("hex");
const [copyState, setCopyState] = useState<CopyState>("idle");
const [recents, setRecents] = useState<Cell[]>([{ r: 0, c: 3 }]);
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
};
}, []);
const family = FAMILIES[active.r];
const shade = family.shades[active.c];
const hex = shade.hex;
const info = useMemo(() => {
const rgb = hexToRgb(hex);
const hsl = rgbToHsl(rgb);
const onWhite = contrastRatio(hex, PAPER);
const onInk = contrastRatio(hex, INK);
return { rgb, hsl, onWhite, onInk };
}, [hex]);
const value = formatValue(hex, format);
const token = `--brand-${family.name.toLowerCase()}-${shade.step}`;
const select = useCallback((cell: Cell) => {
setActive(cell);
setCopyState("idle");
setRecents((prev) => {
const key = `${cell.r}:${cell.c}`;
const next = prev.filter((p) => `${p.r}:${p.c}` !== key);
next.unshift(cell);
return next.slice(0, 6);
});
}, []);
const focusCell = useCallback((cell: Cell) => {
const el = cellRefs.current[cell.r * COLS + cell.c];
if (el) el.focus();
}, []);
const onGridKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, r: number, c: number) => {
let next: Cell | null = null;
switch (event.key) {
case "ArrowRight":
next = { r, c: (c + 1) % COLS };
break;
case "ArrowLeft":
next = { r, c: (c - 1 + COLS) % COLS };
break;
case "ArrowDown":
next = { r: (r + 1) % ROWS, c };
break;
case "ArrowUp":
next = { r: (r - 1 + ROWS) % ROWS, c };
break;
case "Home":
next = event.ctrlKey ? { r: 0, c: 0 } : { r, c: 0 };
break;
case "End":
next = event.ctrlKey ? { r: ROWS - 1, c: COLS - 1 } : { r, c: COLS - 1 };
break;
case " ":
case "Enter":
next = { r, c };
break;
default:
return;
}
event.preventDefault();
select(next);
focusCell(next);
},
[focusCell, select],
);
const copy = useCallback(async () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
try {
await navigator.clipboard.writeText(value);
setCopyState("done");
} catch {
setCopyState("error");
}
copyTimer.current = setTimeout(() => setCopyState("idle"), 2000);
}, [value]);
const swatchInk = readableOn(hex);
const whiteGrade = grade(info.onWhite);
const inkGrade = grade(info.onInk);
const gradeClass = (tone: "pass" | "large" | "fail"): string => {
if (tone === "pass")
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300";
if (tone === "large")
return "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300";
return "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-300";
};
return (
<section className="relative w-full bg-white px-5 py-20 text-slate-900 sm:px-8 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes csp-pop-in {
from { opacity: 0; transform: scale(.9); }
to { opacity: 1; transform: scale(1); }
}
@keyframes csp-sheen-sweep {
from { transform: translateX(-120%); }
to { transform: translateX(320%); }
}
.csp-pop-in { animation: csp-pop-in 220ms cubic-bezier(.2,.8,.2,1) both; }
.csp-sheen { animation: csp-sheen-sweep 4.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.csp-pop-in, .csp-sheen { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Palette
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
Pick a swatch, copy the token
</h2>
<p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Forty-two swatches across seven families. Click a chip to inspect it, or focus
the grid and steer with the arrow keys — left and right walk the ramp, up and
down jump families.
</p>
</header>
<div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4 sm:p-6 dark:border-slate-800 dark:bg-slate-900/40">
<div className="mb-3 grid grid-cols-[4.5rem_repeat(6,minmax(0,1fr))] items-center gap-2 sm:gap-3">
<span aria-hidden="true" />
{STEPS.map((step) => (
<span
key={step}
aria-hidden="true"
className="text-center text-[0.6875rem] font-medium tabular-nums text-slate-500 dark:text-slate-500"
>
{step}
</span>
))}
</div>
<span id={`${uid}-grid-label`} className="sr-only">
Brand palette swatches
</span>
<div
role="radiogroup"
aria-labelledby={`${uid}-grid-label`}
className="flex flex-col gap-2 sm:gap-3"
>
{FAMILIES.map((fam, r) => (
<div
key={fam.name}
role="presentation"
className="grid grid-cols-[4.5rem_repeat(6,minmax(0,1fr))] items-center gap-2 sm:gap-3"
>
<span
aria-hidden="true"
className="truncate text-xs font-medium text-slate-600 dark:text-slate-400"
>
{fam.name}
</span>
{fam.shades.map((sw, c) => {
const isActive = active.r === r && active.c === c;
return (
<div key={sw.step} role="presentation" className="relative">
<button
type="button"
role="radio"
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
aria-label={`${fam.name} ${sw.step}, ${sw.hex.toUpperCase()}`}
ref={(node) => {
cellRefs.current[r * COLS + c] = node;
}}
onClick={() => select({ r, c })}
onKeyDown={(event) => onGridKeyDown(event, r, c)}
style={{ backgroundColor: sw.hex }}
className="flex h-10 w-full items-center justify-center rounded-lg ring-1 ring-inset ring-slate-900/10 transition-transform duration-150 hover:scale-[1.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 sm:h-11 dark:ring-white/10 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
>
{isActive ? (
<span className="csp-pop-in">
<CheckIcon color={readableOn(sw.hex)} />
</span>
) : null}
</button>
{isActive ? (
<motion.span
aria-hidden="true"
layoutId={reduced ? undefined : `${uid}-ring`}
transition={{ type: "spring", stiffness: 520, damping: 38 }}
className="pointer-events-none absolute -inset-[3px] rounded-[0.75rem] ring-2 ring-slate-900 dark:ring-white"
/>
) : null}
</div>
);
})}
</div>
))}
</div>
<div className="mt-6 border-t border-slate-200 pt-4 dark:border-slate-800">
<p className="text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-500">
Recent
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{recents.map((cell) => {
const rf = FAMILIES[cell.r];
const rs = rf.shades[cell.c];
return (
<li key={`${cell.r}:${cell.c}`}>
<button
type="button"
onClick={() => {
select(cell);
focusCell(cell);
}}
className="flex items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1 pr-2.5 text-xs font-medium text-slate-700 transition-colors hover:border-slate-300 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<span
aria-hidden="true"
style={{ backgroundColor: rs.hex }}
className="h-4 w-4 rounded-full ring-1 ring-inset ring-slate-900/10 dark:ring-white/15"
/>
{rf.name} {rs.step}
</button>
</li>
);
})}
</ul>
</div>
</div>
<aside className="flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-4 sm:p-5 dark:border-slate-800 dark:bg-slate-900/40">
<div
className="relative overflow-hidden rounded-xl ring-1 ring-inset ring-slate-900/10 dark:ring-white/10"
style={{ backgroundColor: hex }}
>
<span
aria-hidden="true"
className="csp-sheen absolute inset-y-0 -left-1/3 w-1/3 bg-white/25 blur-md"
/>
<motion.div
key={hex}
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: [0.2, 0.8, 0.2, 1] }}
className="relative px-4 py-8"
>
<p
className="text-lg font-semibold tracking-tight"
style={{ color: swatchInk }}
>
{family.name} {shade.step}
</p>
<p
className="mt-0.5 text-xs font-medium opacity-80"
style={{ color: swatchInk }}
>
{token}
</p>
</motion.div>
</div>
<fieldset className="min-w-0">
<legend className="sr-only">Colour value format</legend>
<div className="flex gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800">
{(["hex", "rgb", "hsl"] as const).map((f) => (
<label key={f} className="flex-1">
<input
type="radio"
name={`${uid}-format`}
value={f}
checked={format === f}
onChange={() => {
setFormat(f);
setCopyState("idle");
}}
className="peer sr-only"
/>
<span className="block cursor-pointer rounded-md px-2 py-1.5 text-center text-xs font-semibold text-slate-600 transition-colors peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-slate-100 dark:text-slate-400 dark:peer-checked:bg-slate-950 dark:peer-checked:text-white dark:peer-focus-visible:ring-offset-slate-800">
{f.toUpperCase()}
</span>
</label>
))}
</div>
</fieldset>
<button
type="button"
onClick={copy}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-left transition-colors hover:border-slate-300 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-950/50 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<code className="truncate font-mono text-sm text-slate-900 dark:text-slate-100">
{value}
</code>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500 dark:text-slate-400">
{copyState === "done" ? (
<>
<CheckIcon color="currentColor" />
Copied
</>
) : copyState === "error" ? (
<>Press ⌘C</>
) : (
<>
<CopyIcon />
Copy
</>
)}
</span>
</button>
<p aria-live="polite" className="sr-only">
{copyState === "done"
? `${value} copied to clipboard`
: copyState === "error"
? "Clipboard unavailable, copy the value manually"
: ""}
</p>
<dl className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<dt className="text-slate-500 dark:text-slate-500">RGB</dt>
<dd className="text-right font-mono tabular-nums text-slate-700 dark:text-slate-300">
{info.rgb.r}, {info.rgb.g}, {info.rgb.b}
</dd>
<dt className="text-slate-500 dark:text-slate-500">HSL</dt>
<dd className="text-right font-mono tabular-nums text-slate-700 dark:text-slate-300">
{info.hsl.h}°, {info.hsl.s}%, {info.hsl.l}%
</dd>
</dl>
<div className="border-t border-slate-200 pt-3 dark:border-slate-800">
<p className="text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-500">
Text contrast
</p>
<ul className="mt-2 space-y-1.5">
<li className="flex items-center justify-between gap-2 text-xs">
<span className="text-slate-600 dark:text-slate-400">On white</span>
<span className="flex items-center gap-2">
<span className="font-mono tabular-nums text-slate-700 dark:text-slate-300">
{info.onWhite.toFixed(2)}:1
</span>
<span
className={`rounded px-1.5 py-0.5 text-[0.625rem] font-semibold ${gradeClass(whiteGrade.tone)}`}
>
{whiteGrade.label}
</span>
</span>
</li>
<li className="flex items-center justify-between gap-2 text-xs">
<span className="text-slate-600 dark:text-slate-400">On near-black</span>
<span className="flex items-center gap-2">
<span className="font-mono tabular-nums text-slate-700 dark:text-slate-300">
{info.onInk.toFixed(2)}:1
</span>
<span
className={`rounded px-1.5 py-0.5 text-[0.625rem] font-semibold ${gradeClass(inkGrade.tone)}`}
>
{inkGrade.label}
</span>
</span>
</li>
</ul>
<p className="mt-2.5 text-[0.6875rem] leading-relaxed text-slate-500 dark:text-slate-500">
Ratios use the WCAG 2.1 relative-luminance formula. 4.5:1 clears AA for body
text, 3:1 for 18px+ bold.
</p>
</div>
</aside>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Colour Gradient Picker
Originalgradient builder picker

Colour Hue Wheel
Originalhue wheel colour picker

Colour Palette
Originalpalette generator with copy

Colour Picker Panel
Originalfull colour picker panel with sliders

Colour Hex Input
Originalhex/rgb input with preview

Colour Shades
Originalshade and tint scale generator

Colour Scheme
Originalcolour scheme preview swatches

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

