Web InnoventixFreeCode

Colour Swatch Picker

Original · free

swatch colour picker

byWeb InnoventixReact + Tailwind
colorswatchpickerpickers
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-swatch-picker.json
color-swatch-picker.tsx
"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

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 →