Web InnoventixFreeCode

Colour Scheme

Original · free

colour scheme preview swatches

byWeb InnoventixReact + Tailwind
colorschemepickers
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-scheme.json
color-scheme.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type HarmonyId =
  | "complementary"
  | "analogous"
  | "triadic"
  | "split"
  | "tetradic"
  | "mono";

type Step = { role: string; dh: number; ds: number; dl: number };
type Harmony = { id: HarmonyId; label: string; blurb: string; steps: Step[] };
type Swatch = { role: string; slug: string; h: number; s: number; l: number; hex: string };
type Canvas = "light" | "dark";
type CopyState = { key: string; ok: boolean } | null;

const HARMONIES: Harmony[] = [
  {
    id: "complementary",
    label: "Complementary",
    blurb:
      "Two hues from opposite ends of the wheel. Highest tension of the six — keep the accent under 10% of the surface or it fights the primary.",
    steps: [
      { role: "Primary", dh: 0, ds: 0, dl: 0 },
      { role: "Primary soft", dh: 0, ds: -18, dl: 20 },
      { role: "Accent", dh: 180, ds: -4, dl: 2 },
      { role: "Accent deep", dh: 180, ds: 6, dl: -16 },
    ],
  },
  {
    id: "analogous",
    label: "Analogous",
    blurb:
      "Neighbours within a 90° arc. Reads calm and cohesive, which is why it suits long-form reading surfaces and empty states.",
    steps: [
      { role: "Lead", dh: -30, ds: -6, dl: 6 },
      { role: "Primary", dh: 0, ds: 0, dl: 0 },
      { role: "Trail", dh: 30, ds: -4, dl: 4 },
      { role: "Anchor", dh: 60, ds: 8, dl: -18 },
    ],
  },
  {
    id: "triadic",
    label: "Triadic",
    blurb:
      "Three hues spaced 120° apart. Vivid and balanced — best when one hue leads and the other two stay in badges, charts and icons.",
    steps: [
      { role: "Primary", dh: 0, ds: 0, dl: 0 },
      { role: "Second", dh: 120, ds: -6, dl: 4 },
      { role: "Third", dh: 240, ds: -6, dl: 4 },
    ],
  },
  {
    id: "split",
    label: "Split complement",
    blurb:
      "The complement, split into the two hues either side of it. You keep the contrast of a complementary pair and lose most of the clash.",
    steps: [
      { role: "Primary", dh: 0, ds: 0, dl: 0 },
      { role: "Split warm", dh: 150, ds: -4, dl: 4 },
      { role: "Split cool", dh: 210, ds: -4, dl: 4 },
      { role: "Grounding", dh: 180, ds: 10, dl: -22 },
    ],
  },
  {
    id: "tetradic",
    label: "Tetradic",
    blurb:
      "Two complementary pairs on a rectangle. Four hues is a lot of budget — pick one dominant and let the rest carry data, not chrome.",
    steps: [
      { role: "Primary", dh: 0, ds: 0, dl: 0 },
      { role: "Offset", dh: 90, ds: -8, dl: 6 },
      { role: "Complement", dh: 180, ds: -4, dl: 2 },
      { role: "Counter", dh: 270, ds: -8, dl: 6 },
    ],
  },
  {
    id: "mono",
    label: "Monochromatic",
    blurb:
      "One hue, five lightness stops. Nothing ever clashes, so hierarchy has to come from weight, spacing and contrast instead.",
    steps: [
      { role: "Wash", dh: 0, ds: -26, dl: 30 },
      { role: "Light", dh: 0, ds: -12, dl: 16 },
      { role: "Primary", dh: 0, ds: 0, dl: 0 },
      { role: "Dark", dh: 0, ds: 4, dl: -16 },
      { role: "Deepest", dh: 0, ds: 8, dl: -28 },
    ],
  },
];

const clamp = (v: number, lo: number, hi: number): number =>
  Math.min(hi, Math.max(lo, v));

const wrapHue = (h: number): number => ((h % 360) + 360) % 360;

function hslToHex(h: number, s: number, l: number): string {
  const hn = wrapHue(h);
  const sn = clamp(s, 0, 100) / 100;
  const ln = clamp(l, 0, 100) / 100;
  const c = (1 - Math.abs(2 * ln - 1)) * sn;
  const x = c * (1 - Math.abs(((hn / 60) % 2) - 1));
  const m = ln - c / 2;
  let r = 0;
  let g = 0;
  let b = 0;
  if (hn < 60) {
    r = c;
    g = x;
  } else if (hn < 120) {
    r = x;
    g = c;
  } else if (hn < 180) {
    g = c;
    b = x;
  } else if (hn < 240) {
    g = x;
    b = c;
  } else if (hn < 300) {
    r = x;
    b = c;
  } else {
    r = c;
    b = x;
  }
  const to = (v: number): string =>
    Math.round((v + m) * 255)
      .toString(16)
      .padStart(2, "0");
  return `#${to(r)}${to(g)}${to(b)}`;
}

function channel(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 h = hex.replace("#", "");
  const r = parseInt(h.slice(0, 2), 16);
  const g = parseInt(h.slice(2, 4), 16);
  const b = parseInt(h.slice(4, 6), 16);
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

function contrast(a: string, b: string): number {
  const la = luminance(a);
  const lb = luminance(b);
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}

function inkOn(hex: string): string {
  return contrast(hex, "#ffffff") >= 3.4 ? "#ffffff" : "#0b1120";
}

function slugify(role: string): string {
  return role.toLowerCase().replace(/[^a-z0-9]+/g, "-");
}

function CopyGlyph() {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <rect x="7.5" y="7.5" width="8.5" height="8.5" rx="2" />
      <path d="M12.5 4.5H6A1.5 1.5 0 0 0 4.5 6v6.5" />
    </svg>
  );
}

function TickGlyph({ tone }: { tone: string }) {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className="h-3.5 w-3.5"
      fill="none"
      stroke={tone}
      strokeWidth={2.4}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M4.6 10.6 8.2 14.2 15.4 6.5" />
    </svg>
  );
}

function ShuffleGlyph() {
  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"
    >
      <path d="M3 6h3.2l7.6 8H17" />
      <path d="M3 14h3.2l2.4-2.6" />
      <path d="M11.4 8.2 13.8 6H17" />
      <path d="m14.6 3.6 2.4 2.4-2.4 2.4" />
      <path d="m14.6 11.6 2.4 2.4-2.4 2.4" />
    </svg>
  );
}

export default function ColorScheme() {
  const uid = useId();
  const reduced = useReducedMotion();

  const [harmonyId, setHarmonyId] = useState<HarmonyId>("split");
  const [hue, setHue] = useState<number>(258);
  const [sat, setSat] = useState<number>(74);
  const [light, setLight] = useState<number>(54);
  const [canvas, setCanvas] = useState<Canvas>("light");
  const [copied, setCopied] = useState<CopyState>(null);

  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    return () => {
      if (timer.current) clearTimeout(timer.current);
    };
  }, []);

  const harmony = useMemo(
    () => HARMONIES.find((h) => h.id === harmonyId) ?? HARMONIES[0],
    [harmonyId],
  );

  const swatches = useMemo<Swatch[]>(
    () =>
      harmony.steps.map((step) => {
        const h = wrapHue(hue + step.dh);
        const s = clamp(sat + step.ds, 8, 100);
        const l = clamp(light + step.dl, 8, 94);
        return { role: step.role, slug: slugify(step.role), h, s, l, hex: hslToHex(h, s, l) };
      }),
    [harmony, hue, sat, light],
  );

  const surface = useMemo(() => {
    if (canvas === "dark") {
      return {
        page: hslToHex(hue, clamp(sat * 0.28, 8, 26), 9),
        card: hslToHex(hue, clamp(sat * 0.24, 6, 22), 14),
        line: hslToHex(hue, clamp(sat * 0.3, 8, 28), 24),
        ink: hslToHex(hue, 14, 96),
        mute: hslToHex(hue, 12, 68),
      };
    }
    return {
      page: hslToHex(hue, clamp(sat * 0.4, 10, 42), 97),
      card: hslToHex(hue, clamp(sat * 0.5, 10, 46), 99.5),
      line: hslToHex(hue, clamp(sat * 0.35, 8, 32), 88),
      ink: hslToHex(hue, 26, 13),
      mute: hslToHex(hue, 14, 42),
    };
  }, [canvas, hue, sat]);

  const primary = swatches.find((s) => s.role === "Primary") ?? swatches[0];
  const accent = swatches[swatches.length - 1] ?? primary;

  const cssBlock = useMemo(() => {
    const lines = swatches.map((s) => `  --scheme-${s.slug}: ${s.hex.toUpperCase()};`);
    return [`/* ${harmony.label} · base hsl(${Math.round(hue)} ${sat}% ${light}%) */`, ":root {", ...lines, "}"].join(
      "\n",
    );
  }, [swatches, harmony.label, hue, sat, light]);

  const write = useCallback(async (key: string, text: string) => {
    if (timer.current) clearTimeout(timer.current);
    let ok = true;
    try {
      await navigator.clipboard.writeText(text);
    } catch {
      ok = false;
    }
    setCopied({ key, ok });
    timer.current = setTimeout(() => setCopied(null), 1800);
  }, []);

  const roll = useCallback(() => {
    setHue(Math.floor(Math.random() * 360));
    setSat(58 + Math.floor(Math.random() * 32));
    setLight(44 + Math.floor(Math.random() * 18));
    setCopied(null);
  }, []);

  const hueTrack = `linear-gradient(to right, ${[0, 60, 120, 180, 240, 300, 360]
    .map((h) => `${hslToHex(h, sat, light)} ${(h / 360) * 100}%`)
    .join(", ")})`;
  const satTrack = `linear-gradient(to right, ${hslToHex(hue, 8, light)}, ${hslToHex(hue, 100, light)})`;
  const lightTrack = `linear-gradient(to right, ${hslToHex(hue, sat, 22)}, ${hslToHex(hue, sat, 54)}, ${hslToHex(hue, sat, 86)})`;

  const bars = [62, 88, 41, 74, 96, 53];

  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 csch-copy-pop {
          0% { transform: scale(.82); opacity: 0; }
          60% { transform: scale(1.06); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes csch-bar-rise {
          from { transform: scaleY(.12); opacity: .3; }
          to { transform: scaleY(1); opacity: 1; }
        }
        .csch-copy-pop { animation: csch-copy-pop 240ms cubic-bezier(.2,.8,.2,1) both; }
        .csch-bar { transform-origin: bottom; animation: csch-bar-rise 620ms cubic-bezier(.2,.8,.2,1) both; }
        .csch-range {
          -webkit-appearance: none;
          appearance: none;
          width: 100%;
          height: 1.25rem;
          border-radius: 9999px;
          background-repeat: no-repeat;
          background-size: 100% 100%;
          cursor: pointer;
        }
        .csch-range::-webkit-slider-runnable-track {
          height: 1.25rem;
          border-radius: 9999px;
          background: transparent;
        }
        .csch-range::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          height: 1.25rem;
          width: 1.25rem;
          border-radius: 9999px;
          background: #ffffff;
          border: 2px solid rgba(15, 23, 42, .78);
          box-shadow: 0 1px 4px rgba(2, 6, 23, .35);
        }
        .csch-range::-moz-range-track {
          height: 1.25rem;
          border-radius: 9999px;
          background: transparent;
        }
        .csch-range::-moz-range-thumb {
          height: 1.125rem;
          width: 1.125rem;
          border-radius: 9999px;
          background: #ffffff;
          border: 2px solid rgba(15, 23, 42, .78);
          box-shadow: 0 1px 4px rgba(2, 6, 23, .35);
        }
        @media (prefers-reduced-motion: reduce) {
          .csch-copy-pop, .csch-bar { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        <header className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Colour scheme
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
            Six harmonies, one base hue, previewed on real UI
          </h2>
          <p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Drag the base hue and the scheme rebuilds from colour-wheel maths — no lookup
            table. Every swatch reports its contrast against the preview surface, so you
            find out a pairing fails WCAG here rather than in a review three weeks from now.
          </p>
        </header>

        <div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
          <div className="flex flex-col gap-6 rounded-2xl border border-slate-200 bg-slate-50/70 p-4 sm:p-6 dark:border-slate-800 dark:bg-slate-900/40">
            <fieldset>
              <legend className="text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-500">
                Harmony
              </legend>
              <div className="mt-2.5 flex flex-wrap gap-1.5">
                {HARMONIES.map((h) => (
                  <label key={h.id}>
                    <input
                      type="radio"
                      name={`${uid}-harmony`}
                      value={h.id}
                      checked={harmonyId === h.id}
                      onChange={() => {
                        setHarmonyId(h.id);
                        setCopied(null);
                      }}
                      className="peer sr-only"
                    />
                    <span className="block cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 peer-checked:border-slate-900 peer-checked:bg-slate-900 peer-checked:text-white peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-400 dark:hover:text-slate-100 dark:peer-checked:border-white dark:peer-checked:bg-white dark:peer-checked:text-slate-950 dark:peer-focus-visible:ring-offset-slate-900">
                      {h.label}
                    </span>
                  </label>
                ))}
              </div>
              <p className="mt-3 max-w-xl text-xs leading-relaxed text-slate-600 dark:text-slate-400">
                {harmony.blurb}
              </p>
            </fieldset>

            <div className="grid gap-4">
              <div>
                <div className="flex items-baseline justify-between gap-3">
                  <label
                    htmlFor={`${uid}-hue`}
                    className="text-xs font-medium text-slate-700 dark:text-slate-300"
                  >
                    Base hue
                  </label>
                  <span className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-500">
                    {Math.round(hue)}°
                  </span>
                </div>
                <input
                  id={`${uid}-hue`}
                  type="range"
                  min={0}
                  max={359}
                  step={1}
                  value={hue}
                  onChange={(event) => {
                    setHue(Number(event.target.value));
                    setCopied(null);
                  }}
                  style={{ backgroundImage: hueTrack }}
                  className="csch-range mt-2 ring-1 ring-inset ring-slate-900/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:ring-white/15 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
                />
              </div>

              <div>
                <div className="flex items-baseline justify-between gap-3">
                  <label
                    htmlFor={`${uid}-sat`}
                    className="text-xs font-medium text-slate-700 dark:text-slate-300"
                  >
                    Saturation
                  </label>
                  <span className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-500">
                    {sat}%
                  </span>
                </div>
                <input
                  id={`${uid}-sat`}
                  type="range"
                  min={12}
                  max={100}
                  step={1}
                  value={sat}
                  onChange={(event) => {
                    setSat(Number(event.target.value));
                    setCopied(null);
                  }}
                  style={{ backgroundImage: satTrack }}
                  className="csch-range mt-2 ring-1 ring-inset ring-slate-900/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:ring-white/15 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
                />
              </div>

              <div>
                <div className="flex items-baseline justify-between gap-3">
                  <label
                    htmlFor={`${uid}-light`}
                    className="text-xs font-medium text-slate-700 dark:text-slate-300"
                  >
                    Lightness
                  </label>
                  <span className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-500">
                    {light}%
                  </span>
                </div>
                <input
                  id={`${uid}-light`}
                  type="range"
                  min={26}
                  max={72}
                  step={1}
                  value={light}
                  onChange={(event) => {
                    setLight(Number(event.target.value));
                    setCopied(null);
                  }}
                  style={{ backgroundImage: lightTrack }}
                  className="csch-range mt-2 ring-1 ring-inset ring-slate-900/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:ring-white/15 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
                />
              </div>
            </div>

            <div>
              <div className="flex items-baseline justify-between gap-3">
                <p className="text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-500">
                  Swatches
                </p>
                <p className="text-[0.6875rem] text-slate-500 dark:text-slate-500">
                  Ratio vs. {canvas} surface
                </p>
              </div>
              <ul className="mt-2.5 flex flex-wrap gap-2.5">
                {swatches.map((sw) => {
                  const ratio = contrast(sw.hex, surface.card);
                  const pass = ratio >= 4.5;
                  const large = !pass && ratio >= 3;
                  const isCopied = copied?.key === sw.slug;
                  return (
                    <li key={sw.slug} className="min-w-[8.5rem] flex-1">
                      <button
                        type="button"
                        onClick={() => write(sw.slug, sw.hex.toUpperCase())}
                        aria-label={`Copy ${sw.role} ${sw.hex.toUpperCase()} to clipboard`}
                        className="group block w-full overflow-hidden rounded-xl border border-slate-200 bg-white text-left transition-colors hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-950/50 dark:hover:border-slate-600 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
                      >
                        <span
                          className="flex h-16 items-center justify-center"
                          style={{ backgroundColor: sw.hex }}
                        >
                          {isCopied ? (
                            <span className="csch-copy-pop inline-flex items-center gap-1 rounded-full bg-white/85 px-2 py-0.5 text-[0.625rem] font-semibold text-slate-900">
                              <TickGlyph tone="#0b1120" />
                              {copied?.ok ? "Copied" : "Copy failed"}
                            </span>
                          ) : (
                            <span
                              className="opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
                              style={{ color: inkOn(sw.hex) }}
                            >
                              <CopyGlyph />
                            </span>
                          )}
                        </span>
                        <span aria-hidden="true" className="flex h-2 w-full">
                          {[22, 12, -14].map((d) => (
                            <span
                              key={d}
                              className="flex-1"
                              style={{
                                backgroundColor: hslToHex(
                                  sw.h,
                                  clamp(sw.s - d * 0.4, 8, 100),
                                  clamp(sw.l + d, 8, 96),
                                ),
                              }}
                            />
                          ))}
                        </span>
                        <span className="block px-2.5 py-2">
                          <span className="block truncate text-xs font-semibold text-slate-800 dark:text-slate-200">
                            {sw.role}
                          </span>
                          <motion.span
                            key={sw.hex}
                            initial={reduced ? false : { opacity: 0, y: 3 }}
                            animate={{ opacity: 1, y: 0 }}
                            transition={{ duration: 0.18, ease: [0.2, 0.8, 0.2, 1] }}
                            className="mt-0.5 block font-mono text-[0.6875rem] tabular-nums text-slate-500 dark:text-slate-500"
                          >
                            {sw.hex.toUpperCase()}
                          </motion.span>
                          <span
                            className={`mt-1.5 inline-block rounded px-1.5 py-0.5 text-[0.625rem] font-semibold tabular-nums ${
                              pass
                                ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
                                : large
                                  ? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300"
                                  : "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-300"
                            }`}
                          >
                            {ratio.toFixed(2)}:1 {pass ? "AA" : large ? "AA Large" : "Fail"}
                          </span>
                        </span>
                      </button>
                    </li>
                  );
                })}
              </ul>
            </div>

            <div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-4 dark:border-slate-800">
              <button
                type="button"
                onClick={roll}
                className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-semibold 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-950/60 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                <ShuffleGlyph />
                Roll a base
              </button>
              <button
                type="button"
                onClick={() => write("css", cssBlock)}
                className="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-slate-800 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-white dark:text-slate-950 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
              >
                {copied?.key === "css" ? (
                  <>
                    <TickGlyph tone="currentColor" />
                    {copied.ok ? "CSS copied" : "Copy failed"}
                  </>
                ) : (
                  <>
                    <CopyGlyph />
                    Copy CSS variables
                  </>
                )}
              </button>
              <p className="text-[0.6875rem] text-slate-500 dark:text-slate-500">
                {swatches.length} custom properties, named by role.
              </p>
            </div>
            <p aria-live="polite" className="sr-only">
              {copied
                ? copied.ok
                  ? `${copied.key === "css" ? "CSS variables" : copied.key} copied to clipboard`
                  : "Clipboard unavailable — select the value and copy it manually"
                : ""}
            </p>
          </div>

          <div className="flex flex-col gap-3">
            <div className="flex items-center justify-between gap-3">
              <p className="text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-500">
                Live preview
              </p>
              <fieldset className="flex gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800">
                <legend className="sr-only">Preview surface</legend>
                {(["light", "dark"] as const).map((mode) => (
                  <label key={mode}>
                    <input
                      type="radio"
                      name={`${uid}-canvas`}
                      value={mode}
                      checked={canvas === mode}
                      onChange={() => setCanvas(mode)}
                      className="peer sr-only"
                    />
                    <span className="block cursor-pointer rounded-md px-2.5 py-1 text-[0.6875rem] font-semibold capitalize text-slate-600 transition-colors peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm peer-focus-visible:outline-none 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">
                      {mode}
                    </span>
                  </label>
                ))}
              </fieldset>
            </div>

            <motion.div
              key={`${harmony.id}-${canvas}`}
              initial={reduced ? false : { opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.24, ease: [0.2, 0.8, 0.2, 1] }}
              className="rounded-2xl p-4 ring-1 ring-inset ring-slate-900/10 sm:p-5 dark:ring-white/10"
              style={{ backgroundColor: surface.page }}
            >
              <div
                className="rounded-xl p-4 sm:p-5"
                style={{ backgroundColor: surface.card, boxShadow: `0 1px 2px ${surface.line}` }}
              >
                <div className="flex items-center justify-between gap-3">
                  <div className="flex items-center gap-2">
                    <span
                      aria-hidden="true"
                      className="grid h-7 w-7 place-items-center rounded-lg text-xs font-bold"
                      style={{ backgroundColor: primary.hex, color: inkOn(primary.hex) }}
                    >
                      HV
                    </span>
                    <span
                      className="text-sm font-semibold tracking-tight"
                      style={{ color: surface.ink }}
                    >
                      Harvest View
                    </span>
                  </div>
                  <span
                    className="rounded-full px-2 py-0.5 text-[0.625rem] font-semibold"
                    style={{ backgroundColor: accent.hex, color: inkOn(accent.hex) }}
                  >
                    Beta
                  </span>
                </div>

                <p
                  className="mt-4 text-base font-semibold leading-snug tracking-tight"
                  style={{ color: surface.ink }}
                >
                  Irrigation held at 82% of target
                </p>
                <p className="mt-1.5 text-xs leading-relaxed" style={{ color: surface.mute }}>
                  Six sensors reported through the night. Block C drew 340 litres more than
                  the schedule allows — worth a look before Thursday.
                </p>

                <div className="mt-4 flex items-end gap-1.5" aria-hidden="true">
                  {bars.map((v, i) => {
                    const sw = swatches[i % swatches.length];
                    return (
                      <span
                        key={v}
                        className="csch-bar flex-1 rounded-t-sm"
                        style={{
                          height: `${(v / 100) * 72}px`,
                          backgroundColor: sw.hex,
                          animationDelay: `${i * 55}ms`,
                        }}
                      />
                    );
                  })}
                </div>
                <div
                  className="mt-1.5 h-px w-full"
                  aria-hidden="true"
                  style={{ backgroundColor: surface.line }}
                />
                <p className="mt-1.5 text-[0.625rem]" style={{ color: surface.mute }}>
                  Mon — Sat, litres per hectare
                </p>

                <div className="mt-4 flex flex-wrap gap-2">
                  <span
                    className="rounded-lg px-3 py-1.5 text-xs font-semibold"
                    style={{ backgroundColor: primary.hex, color: inkOn(primary.hex) }}
                  >
                    Open block C
                  </span>
                  <span
                    className="rounded-lg px-3 py-1.5 text-xs font-semibold"
                    style={{
                      color: surface.ink,
                      boxShadow: `inset 0 0 0 1px ${surface.line}`,
                    }}
                  >
                    Snooze 24h
                  </span>
                </div>
              </div>

              <p className="mt-3 px-1 text-[0.625rem] leading-relaxed" style={{ color: surface.mute }}>
                Static mock — the swatches above drive every colour in this card, including
                the surface tints, which are derived from the base hue at low saturation.
              </p>
            </motion.div>

            <pre className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-3 font-mono text-[0.6875rem] leading-relaxed text-slate-700 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300">
              <code>{cssBlock}</code>
            </pre>
          </div>
        </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 →