Web InnoventixFreeCode

Colour Shades

Original · free

shade and tint scale generator

byWeb InnoventixReact + Tailwind
colorshadespickers
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-shades.json
color-shades.tsx
"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}&deg;
                </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&rsquo;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

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 →