Colour Palette
Original · freepalette generator with copy
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-palette.json"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type HarmonyId = "analogous" | "monochrome" | "complement" | "triad" | "split";
type Stop = { h: number; s: number; l: number; locked: boolean };
type Format = "css" | "tailwind" | "json" | "list";
type Harmony = {
id: HarmonyId;
label: string;
hint: string;
offsets: number[];
};
const HARMONIES: Harmony[] = [
{
id: "analogous",
label: "Analogous",
hint: "Neighbouring hues. Calm, cohesive, hard to get wrong.",
offsets: [-36, -18, 0, 18, 36],
},
{
id: "monochrome",
label: "Monochrome",
hint: "One hue at five weights. The safest product scale.",
offsets: [0, 0, 0, 0, 0],
},
{
id: "complement",
label: "Complement",
hint: "Base hue plus its opposite. Loud, good for CTAs.",
offsets: [0, 0, 0, 180, 180],
},
{
id: "triad",
label: "Triad",
hint: "Three evenly spaced hues. Playful and brand-forward.",
offsets: [0, 120, 240, 120, 0],
},
{
id: "split",
label: "Split",
hint: "Base plus two near-opposites. Contrast without the clash.",
offsets: [0, 0, 152, 208, 180],
},
];
const FORMATS: { id: Format; label: string }[] = [
{ id: "css", label: "CSS vars" },
{ id: "tailwind", label: "Tailwind" },
{ id: "json", label: "JSON" },
{ id: "list", label: "Hex list" },
];
const TOKENS = ["100", "300", "500", "700", "900"];
const LIGHTNESS = [92, 77, 59, 41, 23];
const SAT_MUL = [0.48, 0.72, 1, 0.9, 0.7];
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
function wrapHue(h: number): number {
return ((h % 360) + 360) % 360;
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hslToHex(h: number, s: number, l: number): string {
const sn = s / 100;
const ln = l / 100;
const a = sn * Math.min(ln, 1 - ln);
const k = (n: number): number => (n + h / 30) % 12;
const f = (n: number): number =>
ln - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const hex = (v: number): string =>
Math.round(255 * v)
.toString(16)
.padStart(2, "0");
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
}
function luminance(hex: string): number {
const c = hex.replace("#", "");
const chan = (i: number): number => {
const v = parseInt(c.slice(i, i + 2), 16) / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * chan(0) + 0.7152 * chan(2) + 0.0722 * chan(4);
}
function contrast(a: string, b: string): number {
const l1 = luminance(a);
const l2 = luminance(b);
const hi = Math.max(l1, l2);
const lo = Math.min(l1, l2);
return (hi + 0.05) / (lo + 0.05);
}
function inkOn(hex: string): string {
return contrast(hex, "#ffffff") >= contrast(hex, "#0b0b0f")
? "#ffffff"
: "#0b0b0f";
}
function hueName(h: number, s: number): string {
if (s < 12) return "Slate";
if (h < 15 || h >= 345) return "Rose";
if (h < 45) return "Amber";
if (h < 70) return "Citron";
if (h < 160) return "Emerald";
if (h < 200) return "Teal";
if (h < 240) return "Sky";
if (h < 276) return "Indigo";
if (h < 312) return "Violet";
return "Fuchsia";
}
function buildPalette(
seed: number,
hue: number,
harmony: HarmonyId,
prev: Stop[] | null,
): Stop[] {
const rnd = mulberry32(seed);
const def = HARMONIES.find((x) => x.id === harmony) ?? HARMONIES[0];
const offsets = def ? def.offsets : [0, 0, 0, 0, 0];
const baseSat = 54 + rnd() * 30;
return LIGHTNESS.map((baseL, i) => {
const old = prev ? prev[i] : undefined;
if (old && old.locked) return old;
const h = wrapHue(hue + (offsets[i] ?? 0) + (rnd() - 0.5) * 8);
const mul =
harmony === "monochrome" ? 0.62 + i * 0.09 : (SAT_MUL[i] ?? 1);
const s = clamp(baseSat * mul + (rnd() - 0.5) * 10, 10, 96);
const l = clamp(baseL + (rnd() - 0.5) * 6, 8, 96);
return { h, s, l, locked: false };
});
}
async function writeClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through to the legacy path */
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
function LockIcon({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<rect x="4" y="10.5" width="16" height="10" rx="2.5" />
{open ? (
<path d="M8 10.5V7a4 4 0 0 1 7.5-1.9" />
) : (
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
)}
<circle cx="12" cy="15.5" r="1.2" fill="currentColor" stroke="none" />
</svg>
);
}
function CopyIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<rect x="9" y="9" width="11" height="11" rx="2.5" />
<path d="M15 5.5A2.5 2.5 0 0 0 12.5 3H6.5A2.5 2.5 0 0 0 4 5.5v6A2.5 2.5 0 0 0 6.5 14" />
</svg>
);
}
function CheckIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M4.5 12.5 9.5 17.5 19.5 7" />
</svg>
);
}
function ShuffleIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M3 7h3.5c1.6 0 2.6.9 3.6 2.3l3.8 5.4c1 1.4 2 2.3 3.6 2.3H21" />
<path d="M3 17h3.5c1.6 0 2.6-.9 3.6-2.3" />
<path d="M14.4 9.3C15.4 7.9 16.4 7 18 7h3" />
<path d="M18.5 4.5 21 7l-2.5 2.5" />
<path d="M18.5 14.5 21 17l-2.5 2.5" />
</svg>
);
}
export default function ColorPalette() {
const reduce = useReducedMotion();
const [hue, setHue] = useState<number>(258);
const [harmony, setHarmony] = useState<HarmonyId>("analogous");
const [seed, setSeed] = useState<number>(7);
const [format, setFormat] = useState<Format>("css");
const [stops, setStops] = useState<Stop[]>(() =>
buildPalette(7, 258, "analogous", null),
);
const [copied, setCopied] = useState<string | null>(null);
const [status, setStatus] = useState<string>("");
const timer = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timer.current !== null) window.clearTimeout(timer.current);
};
}, []);
const flash = useCallback((key: string, message: string) => {
setCopied(key);
setStatus(message);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(null), 1400);
}, []);
const regenerate = useCallback(() => {
const next = (seed * 1664525 + 1013904223) >>> 0;
setSeed(next);
setStops((prev) => buildPalette(next, hue, harmony, prev));
setStatus("New palette generated.");
}, [seed, hue, harmony]);
const onHue = useCallback(
(value: number) => {
setHue(value);
setStops((prev) => buildPalette(seed, value, harmony, prev));
},
[seed, harmony],
);
const onHarmony = useCallback(
(id: HarmonyId) => {
setHarmony(id);
setStops((prev) => buildPalette(seed, hue, id, prev));
},
[seed, hue],
);
const toggleLock = useCallback((index: number) => {
setStops((prev) =>
prev.map((s, i) => (i === index ? { ...s, locked: !s.locked } : s)),
);
}, []);
const swatches = useMemo(
() =>
stops.map((s, i) => {
const hex = hslToHex(s.h, s.s, s.l);
const ink = inkOn(hex);
return {
hex,
ink,
token: TOKENS[i] ?? String(i),
name: `${hueName(s.h, s.s)} ${TOKENS[i] ?? ""}`.trim(),
ratio: contrast(hex, ink),
locked: s.locked,
};
}),
[stops],
);
const exportText = useMemo(() => {
if (format === "css") {
const body = swatches
.map((s) => ` --brand-${s.token}: ${s.hex};`)
.join("\n");
return `:root {\n${body}\n}`;
}
if (format === "tailwind") {
const body = swatches
.map((s) => ` ${s.token}: "${s.hex}",`)
.join("\n");
return `// tailwind.config — theme.extend.colors\ncolors: {\n brand: {\n${body}\n },\n}`;
}
if (format === "json") {
const body = swatches
.map((s) => ` "${s.token}": "${s.hex}"`)
.join(",\n");
return `{\n "brand": {\n${body}\n }\n}`;
}
return swatches.map((s) => s.hex).join("\n");
}, [format, swatches]);
const copySwatch = useCallback(
async (hex: string, index: number) => {
const ok = await writeClipboard(hex);
flash(
`swatch-${index}`,
ok ? `${hex} copied to clipboard.` : `Copy failed. Select ${hex} manually.`,
);
},
[flash],
);
const copyAll = useCallback(async () => {
const ok = await writeClipboard(exportText);
const label = FORMATS.find((f) => f.id === format);
flash(
"all",
ok
? `${label ? label.label : "Palette"} copied to clipboard.`
: "Copy failed. Select the code block manually.",
);
}, [exportText, format, flash]);
const onSectionKeyDown = (e: ReactKeyboardEvent<HTMLElement>) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "g" || e.key === "G") {
e.preventDefault();
regenerate();
}
};
const active = HARMONIES.find((x) => x.id === harmony);
const s100 = swatches[0];
const s300 = swatches[1];
const s500 = swatches[2];
const s900 = swatches[4];
const ring =
"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-zinc-950";
return (
<section
className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-zinc-950 dark:text-zinc-100"
onKeyDown={onSectionKeyDown}
aria-labelledby="cpg-title"
>
<style>{`
@keyframes cpg-sheen {
0% { transform: translateX(-130%) skewX(-18deg); }
100% { transform: translateX(260%) skewX(-18deg); }
}
@keyframes cpg-rise {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.cpg-sheen-el { animation: cpg-sheen 1.15s ease-in-out infinite; }
.cpg-rise-el { animation: cpg-rise 260ms cubic-bezier(0.16, 1, 0.3, 1) both; }
@media (prefers-reduced-motion: reduce) {
.cpg-sheen-el, .cpg-rise-el { 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 generator
</p>
<h2
id="cpg-title"
className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white"
>
Five shades, one hue, zero guesswork
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Pick a harmony, drag the hue, lock the shades you like, and reroll
the rest. Every swatch reports its contrast ratio against the label
sitting on it, so you know what is legible before you ship it.
</p>
</header>
<div className="mt-10 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-zinc-800 dark:bg-zinc-900">
<fieldset>
<legend className="text-sm font-medium text-slate-700 dark:text-zinc-300">
Harmony rule
</legend>
<div className="mt-3 flex flex-wrap gap-2">
{HARMONIES.map((h) => (
<div key={h.id} className="relative">
<input
type="radio"
id={`cpg-h-${h.id}`}
name="cpg-harmony"
value={h.id}
checked={harmony === h.id}
onChange={() => onHarmony(h.id)}
className="peer sr-only"
/>
<label
htmlFor={`cpg-h-${h.id}`}
className="inline-flex cursor-pointer select-none items-center rounded-full border border-slate-200 bg-slate-50 px-3.5 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-zinc-100 dark:peer-checked:border-indigo-500 dark:peer-checked:bg-indigo-500 dark:peer-checked:text-white dark:peer-focus-visible:ring-offset-zinc-900"
>
{h.label}
</label>
</div>
))}
</div>
<p className="mt-3 text-sm text-slate-500 dark:text-zinc-500">
{active ? active.hint : ""}
</p>
</fieldset>
<div className="mt-6 grid gap-5 sm:grid-cols-[1fr_auto] sm:items-end">
<div>
<label
htmlFor="cpg-hue"
className="flex items-baseline justify-between text-sm font-medium text-slate-700 dark:text-zinc-300"
>
<span>Base hue</span>
<span className="font-mono text-xs text-slate-500 dark:text-zinc-500">
{Math.round(hue)}° · {hueName(hue, 70)}
</span>
</label>
<input
id="cpg-hue"
type="range"
min={0}
max={359}
step={1}
value={Math.round(hue)}
onChange={(e) => onHue(Number(e.target.value))}
aria-valuetext={`${Math.round(hue)} degrees, ${hueName(hue, 70)}`}
className={`mt-3 h-2.5 w-full cursor-pointer appearance-none rounded-full accent-indigo-600 dark:accent-indigo-400 ${ring}`}
style={{
background:
"linear-gradient(90deg, hsl(0 85% 55%), hsl(60 85% 55%), hsl(120 85% 45%), hsl(180 85% 45%), hsl(240 85% 60%), hsl(300 85% 55%), hsl(360 85% 55%))",
}}
/>
</div>
<button
type="button"
onClick={regenerate}
className={`group relative inline-flex h-11 items-center justify-center gap-2 overflow-hidden rounded-full bg-slate-900 px-6 text-sm font-semibold text-white transition-colors hover:bg-slate-800 active:bg-slate-900 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 ${ring}`}
>
<span
aria-hidden="true"
className="cpg-sheen-el pointer-events-none absolute inset-y-0 -left-8 w-10 bg-white/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:bg-zinc-900/15"
/>
<ShuffleIcon />
<span className="relative">Generate</span>
<kbd className="relative ml-1 rounded border border-white/25 px-1.5 py-0.5 font-mono text-[10px] font-medium text-white/70 dark:border-zinc-900/25 dark:text-zinc-900/60">
G
</kbd>
</button>
</div>
</div>
<ul className="mt-6 grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3 lg:grid-cols-5">
{swatches.map((sw, i) => (
<li
key={sw.token}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<button
type="button"
onClick={() => void copySwatch(sw.hex, i)}
className={`relative block w-full ${ring}`}
>
<motion.span
aria-hidden="true"
className="absolute inset-0 block"
initial={false}
animate={{ backgroundColor: sw.hex }}
transition={{ duration: reduce ? 0 : 0.45, ease: "easeOut" }}
/>
<span
className="relative flex h-32 flex-col justify-between p-3 text-left sm:h-44"
style={{ color: sw.ink }}
>
<span className="flex items-start justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] opacity-65">
{sw.token}
</span>
<span className="font-mono text-[10px] font-medium opacity-65">
{sw.ratio.toFixed(1)}:1
</span>
</span>
<span>
<span className="block font-mono text-sm font-semibold uppercase tracking-tight">
{sw.hex}
</span>
<AnimatePresence mode="wait" initial={false}>
{copied === `swatch-${i}` ? (
<motion.span
key="done"
className="mt-1 flex items-center gap-1 text-[11px] font-medium"
initial={reduce ? false : { opacity: 0, y: 3 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={{ duration: 0.16 }}
>
<CheckIcon />
Copied
</motion.span>
) : (
<motion.span
key="idle"
className="mt-1 flex items-center gap-1 text-[11px] font-medium"
initial={false}
animate={{ opacity: 0.65 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
>
<CopyIcon />
Copy hex
</motion.span>
)}
</AnimatePresence>
</span>
</span>
</button>
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<span className="truncate text-xs font-medium text-slate-600 dark:text-zinc-400">
{sw.name}
</span>
<button
type="button"
onClick={() => toggleLock(i)}
aria-pressed={sw.locked}
aria-label={
sw.locked
? `Unlock ${sw.name} (${sw.hex}) so it rerolls`
: `Lock ${sw.name} (${sw.hex}) to keep it`
}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors ${
sw.locked
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-200 bg-white text-slate-400 hover:border-slate-300 hover:text-slate-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-500 dark:hover:border-zinc-600 dark:hover:text-zinc-200"
} ${ring}`}
>
<LockIcon open={!sw.locked} />
</button>
</div>
</li>
))}
</ul>
<div className="mt-6 grid gap-6 lg:grid-cols-[1.5fr_1fr]">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<fieldset>
<legend className="sr-only">Export format</legend>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1.5">
{FORMATS.map((f) => (
<div key={f.id} className="relative">
<input
type="radio"
id={`cpg-f-${f.id}`}
name="cpg-format"
value={f.id}
checked={format === f.id}
onChange={() => setFormat(f.id)}
className="peer sr-only"
/>
<label
htmlFor={`cpg-f-${f.id}`}
className="inline-flex cursor-pointer select-none items-center rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 peer-checked:bg-slate-900 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:peer-checked:bg-white dark:peer-checked:text-zinc-900 dark:peer-focus-visible:ring-offset-zinc-900"
>
{f.label}
</label>
</div>
))}
</div>
<button
type="button"
onClick={() => void copyAll()}
className={`inline-flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 text-xs font-semibold text-slate-700 transition-colors hover:border-slate-300 hover:bg-slate-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:border-zinc-600 dark:hover:bg-zinc-700 ${ring}`}
>
{copied === "all" ? (
<span className="text-emerald-600 dark:text-emerald-400">
<CheckIcon />
</span>
) : (
<CopyIcon />
)}
{copied === "all" ? "Copied" : "Copy all"}
</button>
</div>
</fieldset>
<pre
key={format}
className="cpg-rise-el mt-4 max-h-64 overflow-auto rounded-2xl border border-slate-200 bg-slate-50 p-4 font-mono text-xs leading-relaxed text-slate-700 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300"
>
<code>{exportText}</code>
</pre>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-sm font-medium text-slate-700 dark:text-zinc-300">
Live preview
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-zinc-500">
The same five tokens wired into a real card.
</p>
<div
className="mt-4 rounded-2xl border p-4"
style={{
backgroundColor: s100 ? s100.hex : "#eef2ff",
borderColor: s300 ? s300.hex : "#c7d2fe",
}}
>
<p
className="text-[11px] font-semibold uppercase tracking-[0.16em]"
style={{ color: s500 ? s500.hex : "#6366f1" }}
>
Starter
</p>
<p
className="mt-1.5 text-lg font-semibold tracking-tight"
style={{ color: s900 ? s900.hex : "#1e1b4b" }}
>
Ship the whole scale
</p>
<p
className="mt-1 text-xs leading-relaxed"
style={{ color: s900 ? s900.hex : "#1e1b4b", opacity: 0.75 }}
>
Surface, border, accent, and ink all come out of one hue — no
extra colour decisions left to make.
</p>
<span
className="mt-4 inline-flex h-9 items-center rounded-lg px-4 text-xs font-semibold"
style={{
backgroundColor: s500 ? s500.hex : "#6366f1",
color: s500 ? s500.ink : "#ffffff",
}}
>
Use this palette
</span>
</div>
<p className="mt-4 text-xs leading-relaxed text-slate-500 dark:text-zinc-500">
Locked swatches survive a reroll. Press{" "}
<kbd className="rounded border border-slate-300 px-1 py-0.5 font-mono text-[10px] text-slate-600 dark:border-zinc-700 dark:text-zinc-400">
G
</kbd>{" "}
anywhere in this section to generate again.
</p>
</div>
</div>
<p className="sr-only" role="status" aria-live="polite">
{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 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.

