Colour Shades
Original · freeshade and tint scale generator
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-shades.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Rgb = { r: number; g: number; b: number };
type Hsl = { h: number; s: number; l: number };
type Format = "hex" | "rgb" | "hsl";
type ExportKind = "css" | "tailwind" | "json";
type Swatch = {
step: number;
hex: string;
rgb: Rgb;
hsl: Hsl;
onWhite: number;
onBlack: number;
isAnchor: boolean;
};
const SCALE_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
const ANCHOR_STEPS = [300, 400, 500, 600, 700] as const;
const STEP_L: Record<number, number> = {
50: 0.971,
100: 0.938,
200: 0.867,
300: 0.774,
400: 0.665,
500: 0.556,
600: 0.474,
700: 0.396,
800: 0.327,
900: 0.272,
950: 0.166,
};
const PRESETS: { name: string; hex: string }[] = [
{ name: "Indigo", hex: "#6366f1" },
{ name: "Violet", hex: "#8b5cf6" },
{ name: "Sky", hex: "#0ea5e9" },
{ name: "Emerald", hex: "#10b981" },
{ name: "Amber", hex: "#f59e0b" },
{ name: "Rose", hex: "#f43f5e" },
];
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
function hexToRgb(hex: string): Rgb | null {
const raw = hex.trim().replace(/^#/, "");
const full =
raw.length === 3
? raw
.split("")
.map((c) => c + c)
.join("")
: raw;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
};
}
function rgbToHex({ r, g, b }: Rgb): string {
const part = (n: number): string =>
clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
return `#${part(r)}${part(g)}${part(b)}`;
}
function rgbToHsl({ r, g, b }: Rgb): Hsl {
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 };
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60;
else if (max === gn) h = ((bn - rn) / d + 2) * 60;
else h = ((rn - gn) / d + 4) * 60;
return { h, s, l };
}
function hslToRgb({ h, s, l }: Hsl): Rgb {
if (s === 0) {
const v = Math.round(l * 255);
return { r: v, g: v, b: v };
}
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = (((h % 360) + 360) % 360) / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
const m = l - c / 2;
let rp = 0;
let gp = 0;
let bp = 0;
if (hp < 1) [rp, gp, bp] = [c, x, 0];
else if (hp < 2) [rp, gp, bp] = [x, c, 0];
else if (hp < 3) [rp, gp, bp] = [0, c, x];
else if (hp < 4) [rp, gp, bp] = [0, x, c];
else if (hp < 5) [rp, gp, bp] = [x, 0, c];
else [rp, gp, bp] = [c, 0, x];
return {
r: Math.round((rp + m) * 255),
g: Math.round((gp + m) * 255),
b: Math.round((bp + m) * 255),
};
}
function channelLum(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({ r, g, b }: Rgb): number {
return 0.2126 * channelLum(r) + 0.7152 * channelLum(g) + 0.0722 * channelLum(b);
}
function contrast(a: Rgb, b: Rgb): 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);
}
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
const BLACK: Rgb = { r: 10, g: 10, b: 10 };
function formatValue(sw: Swatch, format: Format): string {
if (format === "hex") return sw.hex;
if (format === "rgb") return `rgb(${sw.rgb.r} ${sw.rgb.g} ${sw.rgb.b})`;
return `hsl(${Math.round(sw.hsl.h)} ${Math.round(sw.hsl.s * 100)}% ${Math.round(
sw.hsl.l * 100,
)}%)`;
}
function buildScale(
baseHex: string,
anchor: number,
bow: number,
hueShift: number,
): Swatch[] {
const baseRgb = hexToRgb(baseHex) ?? { r: 99, g: 102, b: 241 };
const base = rgbToHsl(baseRgb);
const anchorTarget = STEP_L[anchor];
const lightCap = Math.max(0.975, Math.min(1, base.l + 0.005));
const darkCap = Math.min(0.09, Math.max(0, base.l - 0.005));
const bowAmt = bow / 100;
return SCALE_STEPS.map((step) => {
const target = STEP_L[step];
let l: number;
if (step === anchor) {
l = base.l;
} else if (target > anchorTarget) {
const t = (target - anchorTarget) / (STEP_L[50] - anchorTarget);
l = base.l + t * (lightCap - base.l);
} else {
const t = (anchorTarget - target) / (anchorTarget - STEP_L[950]);
l = base.l - t * (base.l - darkCap);
}
l = clamp(l, 0, 1);
const edge = Math.abs(l - 0.5) * 2;
const s = clamp(base.s * (1 + bowAmt * (0.4 - edge * 0.85)), 0, 1);
const pos = clamp((0.5 - l) * 2, -1, 1);
const h = (((base.h + pos * hueShift) % 360) + 360) % 360;
const hsl: Hsl = { h, s, l };
const rgb = hslToRgb(hsl);
return {
step,
hex: rgbToHex(rgb),
rgb,
hsl,
onWhite: contrast(rgb, WHITE),
onBlack: contrast(rgb, BLACK),
isAnchor: step === anchor,
};
});
}
function buildExport(
scale: Swatch[],
name: string,
kind: ExportKind,
format: Format,
): string {
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "brand";
if (kind === "css") {
const lines = scale.map(
(sw) => ` --${slug}-${sw.step}: ${formatValue(sw, format)};`,
);
return `:root {\n${lines.join("\n")}\n}`;
}
if (kind === "tailwind") {
const lines = scale.map((sw) => ` ${sw.step}: "${formatValue(sw, format)}",`);
return `// tailwind.config.ts\ntheme: {\n extend: {\n colors: {\n ${slug}: {\n${lines.join(
"\n",
)}\n },\n },\n },\n}`;
}
const entries = scale.map((sw) => ` "${sw.step}": "${formatValue(sw, format)}"`);
return `{\n "name": "${slug}",\n${entries.join(",\n")}\n}`;
}
type Option<T extends string | number> = { value: T; label: string };
function Segmented<T extends string | number>({
label,
options,
value,
onChange,
}: {
label: string;
options: Option<T>[];
value: T;
onChange: (next: T) => void;
}) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const index = options.findIndex((o) => o.value === value);
const move = (dir: number): void => {
const next = (index + dir + options.length) % options.length;
onChange(options[next].value);
refs.current[next]?.focus();
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Home") {
e.preventDefault();
onChange(options[0].value);
refs.current[0]?.focus();
} else if (e.key === "End") {
e.preventDefault();
const last = options.length - 1;
onChange(options[last].value);
refs.current[last]?.focus();
}
};
return (
<div
role="radiogroup"
aria-label={label}
onKeyDown={onKeyDown}
className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900"
>
{options.map((opt, i) => {
const active = opt.value === value;
return (
<button
key={String(opt.value)}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(opt.value)}
className={[
"rounded-md px-3 py-1.5 text-xs font-semibold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900",
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
: "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100",
].join(" ")}
>
{opt.label}
</button>
);
})}
</div>
);
}
function CopyIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5" fill="none">
<rect
x="9"
y="9"
width="11"
height="11"
rx="2"
stroke="currentColor"
strokeWidth="1.8"
/>
<path
d="M5 15V6a2 2 0 0 1 2-2h9"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5" fill="none">
<path
d="m5 12.5 4.5 4.5L19 7"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function DropIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none">
<path
d="M12 3.5c3.2 3.6 5.5 6.6 5.5 9.4a5.5 5.5 0 1 1-11 0c0-2.8 2.3-5.8 5.5-9.4Z"
stroke="currentColor"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path
d="M9.4 13.6a2.6 2.6 0 0 0 2.6 2.6"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
export default function ColorShades() {
const reduced = useReducedMotion();
const uid = useId();
const [baseHex, setBaseHex] = useState<string>("#6366f1");
const [hexDraft, setHexDraft] = useState<string>("#6366f1");
const [name, setName] = useState<string>("brand");
const [anchor, setAnchor] = useState<number>(500);
const [bow, setBow] = useState<number>(35);
const [hueShift, setHueShift] = useState<number>(8);
const [format, setFormat] = useState<Format>("hex");
const [exportKind, setExportKind] = useState<ExportKind>("css");
const [selected, setSelected] = useState<number>(500);
const [focusStep, setFocusStep] = useState<number>(500);
const [copied, setCopied] = useState<string | null>(null);
const [status, setStatus] = useState<string>("");
const swatchRefs = useRef<(HTMLButtonElement | null)[]>([]);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const draftValid = hexToRgb(hexDraft) !== null;
const scale = useMemo(
() => buildScale(baseHex, anchor, bow, hueShift),
[baseHex, anchor, bow, hueShift],
);
const active = scale.find((s) => s.step === selected) ?? scale[5];
const exportText = useMemo(
() => buildExport(scale, name, exportKind, format),
[scale, name, exportKind, format],
);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const copy = useCallback(async (text: string, tag: string, announce: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(tag);
setStatus(`Copied ${announce}`);
} catch {
setCopied(null);
setStatus("Copy failed — select the text and copy manually.");
}
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(null), 1400);
}, []);
const commitHex = (raw: string): void => {
const rgb = hexToRgb(raw);
if (!rgb) return;
const hex = rgbToHex(rgb);
setBaseHex(hex);
setHexDraft(hex);
};
const onSwatchKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => {
const i = SCALE_STEPS.findIndex((s) => s === focusStep);
const go = (next: number): void => {
const step = SCALE_STEPS[next];
setFocusStep(step);
swatchRefs.current[next]?.focus();
};
if (e.key === "ArrowRight") {
e.preventDefault();
go((i + 1) % SCALE_STEPS.length);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
go((i - 1 + SCALE_STEPS.length) % SCALE_STEPS.length);
} else if (e.key === "Home") {
e.preventDefault();
go(0);
} else if (e.key === "End") {
e.preventDefault();
go(SCALE_STEPS.length - 1);
}
};
const rating = (ratio: number): { label: string; pass: boolean } => {
if (ratio >= 4.5) return { label: "AA", pass: true };
if (ratio >= 3) return { label: "AA Large", pass: true };
return { label: "Fail", pass: false };
};
const whiteRating = rating(active.onWhite);
const blackRating = rating(active.onBlack);
return (
<section className="relative w-full bg-white px-4 py-20 text-slate-900 sm:px-6 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes cshade-rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes cshade-ping {
0% { transform: scale(0.85); opacity: 0.55; }
100% { transform: scale(1.45); opacity: 0; }
}
.cshade-rise { animation: cshade-rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
.cshade-ping { animation: cshade-ping 0.9s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cshade-rise, .cshade-ping { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="cshade-rise max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<DropIcon />
Scale generator
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
Tints and shades from one colour
</h2>
<p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Pick a base, choose which step it should anchor to, then tune the chroma bow
and hue drift until the ramp reads evenly. Every swatch copies on click and
reports its WCAG contrast.
</p>
</header>
<div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="order-2 lg:order-1">
<div className="flex flex-wrap items-end justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
{name || "brand"} ramp
</h3>
<Segmented<Format>
label="Copy format"
value={format}
onChange={setFormat}
options={[
{ value: "hex", label: "HEX" },
{ value: "rgb", label: "RGB" },
{ value: "hsl", label: "HSL" },
]}
/>
</div>
<div
role="toolbar"
aria-orientation="horizontal"
aria-label="Generated shades — click a swatch to copy it"
onKeyDown={onSwatchKeyDown}
className="mt-4 grid grid-cols-4 gap-2 sm:grid-cols-6 lg:grid-cols-11"
>
{scale.map((sw, i) => {
const dark = sw.onWhite > sw.onBlack;
const value = formatValue(sw, format);
const tag = `swatch-${sw.step}`;
const isCopied = copied === tag;
return (
<motion.button
key={sw.step}
ref={(el: HTMLButtonElement | null) => {
swatchRefs.current[i] = el;
}}
type="button"
tabIndex={focusStep === sw.step ? 0 : -1}
aria-label={`Step ${sw.step}, ${value}${sw.isAnchor ? ", anchor" : ""}. Copy value`}
onFocus={() => setFocusStep(sw.step)}
onClick={() => {
setSelected(sw.step);
setFocusStep(sw.step);
void copy(value, tag, `${name || "brand"} ${sw.step} — ${value}`);
}}
initial={reduced ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={
reduced
? { duration: 0 }
: { duration: 0.4, delay: i * 0.025, ease: [0.22, 1, 0.36, 1] }
}
className={[
"group relative flex h-24 flex-col justify-between rounded-xl p-2 text-left transition-transform",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950",
"hover:-translate-y-0.5 motion-reduce:hover:translate-y-0",
selected === sw.step
? "ring-2 ring-slate-900 ring-offset-2 ring-offset-white dark:ring-white dark:ring-offset-slate-950"
: "ring-1 ring-inset ring-black/10 dark:ring-white/10",
].join(" ")}
style={{ backgroundColor: sw.hex }}
>
<span
className={[
"text-[11px] font-bold tabular-nums",
dark ? "text-white" : "text-slate-900",
].join(" ")}
>
{sw.step}
{sw.isAnchor ? (
<span className="ml-1 font-normal opacity-70">•</span>
) : null}
</span>
<span
className={[
"font-mono text-[10px] uppercase tracking-tight",
dark ? "text-white/75" : "text-slate-900/70",
].join(" ")}
>
{sw.hex.slice(1)}
</span>
<AnimatePresence>
{isCopied ? (
<motion.span
initial={reduced ? { opacity: 1 } : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
transition={{ duration: reduced ? 0 : 0.16 }}
className={[
"absolute inset-0 flex items-center justify-center rounded-xl text-[11px] font-semibold",
dark ? "bg-black/35 text-white" : "bg-white/45 text-slate-900",
].join(" ")}
>
<CheckIcon />
</motion.span>
) : null}
</AnimatePresence>
</motion.button>
);
})}
</div>
<div className="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/60">
<div className="flex flex-wrap items-center gap-3">
<span
aria-hidden="true"
className="h-10 w-10 shrink-0 rounded-lg ring-1 ring-inset ring-black/10 dark:ring-white/10"
style={{ backgroundColor: active.hex }}
/>
<div className="min-w-0">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">
{name || "brand"}-{active.step}
</p>
<p className="font-mono text-sm text-slate-900 dark:text-slate-100">
{formatValue(active, format)}
</p>
</div>
<div className="ml-auto flex flex-wrap gap-2">
{(["hex", "rgb", "hsl"] as Format[]).map((f) => {
const tag = `detail-${f}`;
return (
<button
key={f}
type="button"
onClick={() =>
void copy(
formatValue(active, f),
tag,
`${f.toUpperCase()} ${formatValue(active, f)}`,
)
}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] font-semibold text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 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-800 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
{copied === tag ? <CheckIcon /> : <CopyIcon />}
{f.toUpperCase()}
</button>
);
})}
</div>
</div>
<dl className="mt-4 grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center justify-between rounded-lg bg-white px-3 py-2 dark:bg-slate-800/70">
<dt className="text-slate-500 dark:text-slate-400">On white</dt>
<dd className="flex items-center gap-2">
<span className="font-mono tabular-nums text-slate-900 dark:text-slate-100">
{active.onWhite.toFixed(2)}
</span>
<span
className={[
"rounded px-1.5 py-0.5 text-[10px] font-bold",
whiteRating.pass
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
].join(" ")}
>
{whiteRating.label}
</span>
</dd>
</div>
<div className="flex items-center justify-between rounded-lg bg-white px-3 py-2 dark:bg-slate-800/70">
<dt className="text-slate-500 dark:text-slate-400">On black</dt>
<dd className="flex items-center gap-2">
<span className="font-mono tabular-nums text-slate-900 dark:text-slate-100">
{active.onBlack.toFixed(2)}
</span>
<span
className={[
"rounded px-1.5 py-0.5 text-[10px] font-bold",
blackRating.pass
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
].join(" ")}
>
{blackRating.label}
</span>
</dd>
</div>
</dl>
</div>
</div>
<div className="order-1 flex flex-col gap-5 rounded-2xl border border-slate-200 bg-slate-50 p-5 lg:order-2 dark:border-slate-800 dark:bg-slate-900/60">
<div>
<label
htmlFor={`${uid}-hex`}
className="block text-xs font-semibold text-slate-700 dark:text-slate-300"
>
Base colour
</label>
<div className="mt-2 flex gap-2">
<div className="relative">
<input
id={`${uid}-picker`}
type="color"
aria-label="Base colour picker"
value={baseHex}
onChange={(e) => {
setBaseHex(e.target.value);
setHexDraft(e.target.value);
}}
className="h-10 w-12 cursor-pointer rounded-lg border border-slate-300 bg-white p-1 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-800 dark:focus-visible:ring-offset-slate-900"
/>
</div>
<input
id={`${uid}-hex`}
type="text"
inputMode="text"
spellCheck={false}
autoComplete="off"
value={hexDraft}
aria-invalid={!draftValid}
aria-describedby={`${uid}-hexhelp`}
onChange={(e) => {
setHexDraft(e.target.value);
commitHex(e.target.value);
}}
onBlur={() => setHexDraft(baseHex)}
className={[
"h-10 w-full rounded-lg border bg-white px-3 font-mono text-sm text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-800 dark:text-slate-100 dark:focus-visible:ring-offset-slate-900",
draftValid
? "border-slate-300 focus-visible:ring-indigo-500 dark:border-slate-700"
: "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70",
].join(" ")}
/>
</div>
<p
id={`${uid}-hexhelp`}
className={[
"mt-1.5 text-[11px]",
draftValid
? "text-slate-500 dark:text-slate-400"
: "text-rose-600 dark:text-rose-400",
].join(" ")}
>
{draftValid
? "3 or 6 digit hex — #6366f1 or #63f."
: "Not a valid hex value yet."}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.hex}
type="button"
aria-label={`Use ${p.name} (${p.hex}) as base`}
aria-pressed={baseHex.toLowerCase() === p.hex}
onClick={() => {
setBaseHex(p.hex);
setHexDraft(p.hex);
setName(p.name.toLowerCase());
}}
className={[
"h-7 w-7 rounded-md transition-transform hover:scale-110 motion-reduce:hover:scale-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:focus-visible:ring-offset-slate-900",
baseHex.toLowerCase() === p.hex
? "ring-2 ring-slate-900 ring-offset-2 ring-offset-slate-50 dark:ring-white dark:ring-offset-slate-900"
: "ring-1 ring-inset ring-black/10 dark:ring-white/10",
].join(" ")}
style={{ backgroundColor: p.hex }}
/>
))}
</div>
</div>
<div>
<label
htmlFor={`${uid}-name`}
className="block text-xs font-semibold text-slate-700 dark:text-slate-300"
>
Token name
</label>
<input
id={`${uid}-name`}
type="text"
value={name}
spellCheck={false}
autoComplete="off"
onChange={(e) => setName(e.target.value)}
className="mt-2 h-10 w-full rounded-lg border border-slate-300 bg-white px-3 font-mono text-sm text-slate-900 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-800 dark:text-slate-100 dark:focus-visible:ring-offset-slate-900"
/>
</div>
<div>
<span className="block text-xs font-semibold text-slate-700 dark:text-slate-300">
Anchor step
</span>
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">
The step that stays exactly your base colour.
</p>
<div className="mt-2">
<Segmented<number>
label="Anchor step"
value={anchor}
onChange={(v) => {
setAnchor(v);
setSelected(v);
}}
options={ANCHOR_STEPS.map((s) => ({ value: s, label: String(s) }))}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor={`${uid}-bow`}
className="text-xs font-semibold text-slate-700 dark:text-slate-300"
>
Chroma bow
</label>
<output
htmlFor={`${uid}-bow`}
className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-400"
>
{bow}%
</output>
</div>
<input
id={`${uid}-bow`}
type="range"
min={0}
max={100}
step={1}
value={bow}
onChange={(e) => setBow(Number(e.target.value))}
className="mt-2 h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 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:bg-slate-700 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
/>
<p className="mt-1.5 text-[11px] text-slate-500 dark:text-slate-400">
Saturates the middle, calms the ends — stops tints going chalky.
</p>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor={`${uid}-hue`}
className="text-xs font-semibold text-slate-700 dark:text-slate-300"
>
Hue drift
</label>
<output
htmlFor={`${uid}-hue`}
className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-400"
>
{hueShift > 0 ? `+${hueShift}` : hueShift}°
</output>
</div>
<input
id={`${uid}-hue`}
type="range"
min={-24}
max={24}
step={1}
value={hueShift}
onChange={(e) => setHueShift(Number(e.target.value))}
className="mt-2 h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 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:bg-slate-700 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
/>
<p className="mt-1.5 text-[11px] text-slate-500 dark:text-slate-400">
Rotates hue across the ramp so darks and lights don’t look flat.
</p>
</div>
<button
type="button"
onClick={() => {
setAnchor(500);
setBow(35);
setHueShift(8);
setSelected(500);
setFocusStep(500);
setStatus("Curve reset to defaults.");
}}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition-colors 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-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
Reset curve
</button>
</div>
</div>
<div className="mt-8 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900/40">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
Export
</h3>
<div className="flex flex-wrap items-center gap-2">
<Segmented<ExportKind>
label="Export format"
value={exportKind}
onChange={setExportKind}
options={[
{ value: "css", label: "CSS vars" },
{ value: "tailwind", label: "Tailwind" },
{ value: "json", label: "JSON" },
]}
/>
<button
type="button"
onClick={() => void copy(exportText, "export", "the full scale")}
className="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-950"
>
{copied === "export" ? <CheckIcon /> : <CopyIcon />}
{copied === "export" ? "Copied" : "Copy all"}
</button>
</div>
</div>
<label htmlFor={`${uid}-export`} className="sr-only">
Generated scale code
</label>
<textarea
id={`${uid}-export`}
readOnly
rows={8}
value={exportText}
onFocus={(e) => e.currentTarget.select()}
className="mt-3 w-full resize-y rounded-xl border border-slate-200 bg-slate-50 p-3 font-mono text-xs leading-relaxed text-slate-800 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-800 dark:bg-slate-950 dark:text-slate-200 dark:focus-visible:ring-offset-slate-950"
/>
</div>
<p aria-live="polite" className="sr-only">
{status}
</p>
</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 Swatch Picker
Originalswatch colour picker

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 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.

