Web InnoventixFreeCode

Colour Palette

Original · free

palette generator with copy

byWeb InnoventixReact + Tailwind
colorpalettepickers
Open

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
color-palette.tsx
"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)}&deg; &middot; {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

motion

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 quote

Similar components

Browse all →